Tutorial: Set up Google OAuth in a Hilla Spring Boot + React project

Tutorial: Set up Google OAuth in a Hilla Spring Boot + React project

In this tutorial, you'll learn how to configure and use Google OAuth authentication in a Hilla project that uses Spring Boot and React.

This tutorial is based on Hilla 2.0 with React.

Source code

You can find the completed source code on GitHub: https://github.com/marcushellberg/hilla-google-auth/

Requirements

  • 30 minutes

  • Java 17 or newer

  • Node 18 or newer

Create a new Hilla project with a React frontend

Generate a new Hilla project with:

npx @hilla/cli init --react --empty hilla-google-auth

Set up a Google OAuth 2.0 Client ID

To create a client ID, you will first need to create a Google Cloud project, and add an OAuth consent screen to that project. To do this, go to the OAuth consent screen on the Google Cloud Platform dashboard. There you can create a new project and create its OAuth consent screen (select “External” when prompted for the “User Type” of the consent screen).

Once the project with a consent screen is created, go to the credentials page and do the following:

  1. Select “Create credentials”, followed by “OAuth client ID”.

  2. When prompted for the application type, select “Web application”.

  3. In the “Authorized redirect URIs” field, add a new redirect for localhost:8080/login/oauth2/code/google.

Once the OAuth client is created, take note of the generated client ID and client secret.

Note: Other OAuth OIDC providers

You can also use other OAuth social login providers like GitHub, Facebook, and Twitter using these same instructions, replacing "google" in the Spring Security configuration and login paths. You can also configure multiple providers.

Add Spring Security dependencies

Begin by adding the following dependencies to the project’s pom.xml file:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>

Configure Spring Security

Next, configure and enable Spring Security. Start by creating a new package com.example.application.security and within it a new file SecurityConfiguration.java with the following content:

package com.example.application.security;

import com.vaadin.flow.spring.security.VaadinWebSecurity;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;

@EnableWebSecurity
@Configuration
public class SecurityConfiguration extends VaadinWebSecurity {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        super.configure(http);
        http.oauth2Login()
                .loginPage("/login").permitAll().and()
                .logout().logoutSuccessUrl("/").permitAll();

    }
}

Then, add the client id and secret you created in the first step to a local application.properties file. It is important that you don’t commit secrets to Git.

In the root of the project, create a new subdirectory config/local and add it to the .gitignore file.

mkdir -p config/local
touch config/local/application.properties
echo "
# Contains local config that shouldn't go into the repository
config/local/" >> .gitignore

Add the following two properties to the newly created application.properties file:

spring.security.oauth2.client.registration.google.client-id=<your client id>

spring.security.oauth2.client.registration.google.client-secret=<your client secret>

Create a login page and a view for logged-in users

Now that you have Spring Security configured, you can create a login page. The actual login is handled by Google, so all you need is a page that links to the correct URL.

In the frontend/views folder, create a new file LoginView.tsx with the following content:

export function LoginView() {
  return (
    <div className="m-m">
      <a href="/oauth2/authorization/google">Log in with Google</a>
    </div>
  )
}

Next, create a view for logged-in users. In the frontend/views folder, create a new file MainView.tsx with the following content:

export function MainView() {

  return (
    <div className="m-m">
      <p>You are logged in</p>
    </div>
  )
}

Finally, update the routing configuration in routes.tsx. Replace the contents of the file with the following:

import { createBrowserRouter } from 'react-router-dom';
import {LoginView} from "Frontend/views/LoginView";
import {MainView} from "Frontend/views/MainView";

const router = createBrowserRouter([
  { path: '/login', element: <LoginView /> },
  { path: '', element: <MainView/>}
]);

export default router;

✅ Checkpoint: run the application and verify it works

Run the application and verify that you are able to log in.

  • Start the application by running Application.java in your IDE, or with the included Maven wrapper: ./mvnw

  • Spring Security should redirect you to /login when the app starts.

  • Once you authenticate using Google, you should see MainView.

Create an endpoint and configure access

Create a server endpoint for fetching information about the logged-in user. In the src/main/java/com/example/application/endpoints folder, create a UserDetails.java record to hold information about the user.

package com.example.application.endpoints;

public record UserDetails(
        String email,
        String name,
        String profilePictureUrl
) {}

In the same package, create a Hilla endpoint, UserEndpoint.java, with the following content:

package com.example.application.endpoints;

import com.vaadin.flow.spring.security.AuthenticationContext;
import dev.hilla.Endpoint;
import jakarta.annotation.security.PermitAll;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;

import java.util.Optional;

@Endpoint
@PermitAll
public class UserEndpoint {

    private final AuthenticationContext authContext;

    public UserEndpoint(AuthenticationContext authContext) {
        this.authContext = authContext;
    }

    public Optional<UserDetails> getAuthenticatedUser() {
        return authContext.getAuthenticatedUser(OidcUser.class)
                .map(u -> new UserDetails(
                        u.getEmail(),
                        u.getFullName(),
                        u.getPicture()
                ));
    }
}

Here are the most important parts of the endpoint code:

  • @Endpoint makes public methods in the class callable from the client through TypeScript methods. It generates corresponding TypeScript types to method parameters and return values.

  • @PermitAll allows all authenticated users to access public methods on the endpoint.

  • The constructor injects AuthenticationContext to access user details.

  • getAuthenticatedUser() gets the authenticated OidcUser and copies over only the needed info into a UserDetails record.

Access the authentication state from React components

Create a custom React hook and provider for fetching the user details and making them available to components.

In the frontend folder, create a new file useAuth.tsx with the following content:

import {
  createContext,
  ReactNode,
  useContext,
  useEffect,
  useState,
} from 'react';
import { UserEndpoint } from 'Frontend/generated/endpoints.js';
import { logout as serverLogout } from '@hilla/frontend';
import UserDetails from 'Frontend/generated/com/example/application/endpoints/UserDetails.js';

export function authHook() {
  const [authenticated, setAuthenticated] = useState(false);
  const [user, setUser] = useState<UserDetails>();
  const [authInitialized, setAuthInitialized] = useState(false);

  async function login() {
    try {
      const authUser = await UserEndpoint.getAuthenticatedUser();
      setUser(authUser);
      setAuthenticated(!!authUser);
    } finally {
      setAuthInitialized(true);
    }
  }

  async function logout(redirect: string = '/login') {
    setAuthenticated(false);
    setUser(undefined);
    setAuthInitialized(false);
    await serverLogout();
    location.href = `${location.origin}${redirect}`;
  }

  useEffect(() => {
    login();
  }, []);

  return {
    authenticated,
    authInitialized,
    user,
    login,
    logout,
  };
}

type AuthContextType = ReturnType<typeof authHook>;

const initialValue: AuthContextType = {
  authenticated: false,
  user: undefined,
  authInitialized: false,
  login: async () => {},
  logout: async () => {},
};

const AuthContext = createContext<AuthContextType>(initialValue);

interface AuthProviderProps {
  children: ReactNode;
}

export function AuthProvider({ children }: AuthProviderProps) {
  const auth = authHook();
  return <AuthContext.Provider value={auth}>{children}</AuthContext.Provider>;
}

export function useAuth() {
  return useContext(AuthContext);
}

Here are the most important parts:

  • The authHook function contains the logic needed for checking the authentication state, accessing user details, and logging out.

  • AuthContext and AuthProvider allow you to have a single, shared, authentication state accessible from any component.

  • useAuth is the hook that gives you access to the auth state and login/logout functionality in your components.

Restrict access to views

Create a component for protecting routes, ProtectedRoute.tsx:

import { ReactElement, ReactNode } from 'react';
import { useAuth } from 'Frontend/useAuth.js';
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { REDIRECT_PATH_KEY } from 'Frontend/routes.js';

interface ProtectedRouteProps {
  redirectPath?: string;
  component: ReactElement;
}

export function ProtectedRoute({
  redirectPath = '/login',
  component,
}: ProtectedRouteProps): ReactElement {
  const { authenticated, authInitialized } = useAuth();
  const location = useLocation();

  if (!authInitialized) {
    return <div></div>;
  }

  if (!authenticated) {
    // Store the requested path, so we can redirect to it after logging in (in App.tsx)
    localStorage.setItem(REDIRECT_PATH_KEY, location.pathname);

    return <Navigate to={redirectPath} replace />;
  }

  return component ? component : <Outlet />;
}

The component:

  • Uses the useAuth() hook to access information on the authentication

  • Displays an empty <div> if auth status is not yet available

  • If the user is not authenticated, it stores the current path to local storage and redirects to the login page.

  • If the user is authenticated, it shows the passed-in component or a router outlet

Update routes.tsx to use the new functionality. Replace the contents with the following:

import { createBrowserRouter } from 'react-router-dom';
import { LoginView } from 'Frontend/views/LoginView';
import { MainView } from 'Frontend/views/MainView';
import { ProtectedRoute } from 'Frontend/ProtectedRoute.js';
import { SecretView } from 'Frontend/views/SecretView.js';

export const REDIRECT_PATH_KEY = 'redirectPath';

const router = createBrowserRouter([
  { path: '/login', element: <LoginView /> },
  { path: '', element: <ProtectedRoute component={<MainView />} /> },
]);

export default router;

Finally, update App.tsx with the following content:

import { RouterProvider, useNavigate } from 'react-router-dom';
import router, { REDIRECT_PATH_KEY } from './routes';
import { AuthProvider } from 'Frontend/useAuth.js';
import { useEffect } from 'react';

export function App() {
  /**
   * Redirects a user back to the view they attempted to access before login
   */
  useEffect(() => {
    const redirectPath = localStorage.getItem(REDIRECT_PATH_KEY);

    if (redirectPath) {
      localStorage.removeItem(REDIRECT_PATH_KEY);
      location.href = `${location.origin}${redirectPath}`;
    }
  }, []);

  return (
    <AuthProvider>
      <RouterProvider router={router} />
    </AuthProvider>
  );
}

The new component:

  • Checks if there is a redirect path stored in local storage. If there is, it navigates there.

  • Wraps the entire application with the AuthProvider to give all components access to the useAuth() hook.

Run the application and verify that you are able to log in.

A browser window displaying a "Log in with Google" link

Display user information and add a logout button

Now that you have access to user information through the useAuth() hook, update MainView.tsx to display user information and a logout button.

import {useAuth} from "Frontend/useAuth.js";
import {Button} from "@hilla/react-components/Button.js";

export function MainView() {
  const {user, logout} = useAuth();

  return (
    <div className="m-m">
      {user && <div>
          <p>You are logged in as {user.name} ({user.email})</p>
          <img src={user.profilePictureUrl} alt={user.name} referrerPolicy="no-referrer"/>
      </div>}
      <p>
        <Button onClick={() => logout()}>Log out</Button>
      </p>
    </div>
  )
}

You should now see information about the logged-in user and be able to log out.

A browser window displaying the name, email, and picture of the logged-in user.

A note on logout

In this example, logging out invalidates the session and logs the user out from only this application. When using a social login like Google, it is not expected behavior to log the user out of all Google services, like Gmail, when logging out.

In some cases, you may want to log the user out of the SSO provider. In that case, you need to perform an RP-initiated logout. Read more about handling Open ID Connect logouts in this blog post from Okta.

Completed application source code

You can find the source code for the completed application on GitHub:

https://github.com/marcushellberg/hilla-google-auth/