Building React SPA with Spring Boot backend and OAuth2 authentication
What we'll be doing
By the end of this article, you'll have created a single-page, statically served application using React. For authentication and database access, a Spring Boot application is deployed while authenticating to an OAuth2 provider. The content is served through an nginx reverse proxy.
A brief description of the setup. We're using Discord as a placeholder for the OAuth provider, which could be Discord, Microsoft, GitHub, or any other provider you'd like to connect to.
To map OAuth users to local users, create a user entity. In this example, we're using the Discord snowflakes as unique identifiers. Therefore, we are not using the @GeneratedValue annotation to generate them.
L18: Specify the URL to send users to when login is required.
L19: As with logout, the user will be redirected to this endpoint upon successful login.
L20: This is where the OAuth user request is consumed. This step creates the OAuth2User from the data returned by the provider after login. For example, creating the user if it doesn't already exist in the database and granting authorities. To make things easier, I've excluded the CustomOauth2UserService here. You may reference the original here.
Spring Security automatically uses the Spring MVC CORS config. In dev mode we need to allow a different origin to allow the requests from the frontend. In production, this isn't an issue as both applications are deployed behind the same URL.
application.properties
server.cors.allowed-origins=http://localhost:3000
Recommended optionals
CSRF protection
OAuth2EndpointConfig.java
privatefinalAuthenticationSuccessHandler authenticationSuccessHandler;@BeanSecurityFilterChainoAuthUserFilterChain(HttpSecurity http) throws Exception { // https://docs.spring.io/spring-security/reference/5.8/migration/servlet/exploits.html#_i_am_using_angularjs_or_another_javascript_framework
finalCookieCsrfTokenRepository tokenRepository =newCookieCsrfTokenRepository();tokenRepository.setCookieCustomizer(cookie -> cookie.path("/").httpOnly(false).secure(true).sameSite(STRICT.attributeValue()) );finalXorCsrfTokenRequestAttributeHandler delegate =newXorCsrfTokenRequestAttributeHandler();// set the name of the attribute the CsrfToken will be populated ondelegate.setCsrfRequestAttributeName("_csrf");// Use only the handle() method of XorCsrfTokenRequestAttributeHandler and the// default implementation of resolveCsrfTokenValue() from CsrfTokenRequestHandlerfinalCsrfTokenRequestHandler requestHandler = delegate::handle; http..csrf(csrf -> csrf.csrfTokenRepository(tokenRepository).csrfTokenRequestHandler(requestHandler)).oauth2Login(login -> login.successHandler(authenticationSuccessHandler) [...] ) [...]}
L9: Make the CSRF token available to Javascript. We'll need this later to authenticate our fetches from the frontend.
@ComponentpublicclassSessionRegistrator { @BeanSessionRegistrysessionRegistry() {returnnewSessionRegistryImpl(); } /** * Needed for session registry to work * * @see SessionRegistryImpl */ @BeanHttpSessionEventPublisherhttpSessionEventPublisher() {returnnewHttpSessionEventPublisher(); }}
Starting the login process
While Spring already provides a default login page, we support the user with some nice to have features. To force a session to be created, we create an endpoint that requires authentication. We'll use the @PreAuthorize method to do this. Remember to add @EnableMethodSecurity to your Spring Boot Application class to enable its use.
We create the endpoint in a way that allows us to redirect to any page after the OAuth flow is complete. Since we are leaving our frontend while redirecting to the external provider, we need a way to get back to the page the user had opened. This is particularly useful if there is a login button in a header and we don't want the user to always restart their journey at the defaultSuccessUrl.
publicinterfaceRedirectService { /** * Creates an absolute path for redirection to the frontend. To do this, the servlet context path is removed * * @param redirectPath relative path */StringredirectTo(String redirectPath);}
To avoid an Open Redirect vulnerability, we'll build the URL from the current request. We remove the servletContextPath (/backend if you're following the setup we'll add later) and append the passed redirectPath. This may have been tampered with, but as it's only on the same domain, we're mostly safe.
We'll use the same redirect logic we built for the login endpoint to redirect to a specific frontend URL. This doesn't have to be the same path, or your redirectService may use some special mapping logic to get the spa routes.
Frontend Routing
The entry point to our single page application is the router. This is where we specify which urls will display which components in the browser. It will include the route we specified in the RedirectController.
I won't describe how to use it here, but you might want to take a look at react routers outlet. This component allows you to keep part of the page as you navigate, without having to re-render everything. Especially useful for headers and footers.
While the Cookies are automatically added to the requests if we're on the same domain-port-pair, you already know it, in dev mode we force axios to send it in every request. This only works if the Access-Control-Allow-Origin and Access-Control-Allow-Credentials headers are sent (Reference WebMvcConfig.java).
That's it! Bind them to any click listener on a button you want, and the backend will automatically set all the necessary cookies and axios will pick them up.
In a context
You may want to access the logged-in user in multiple places, or provide multiple login/logout buttons. That's where an auth context can help.
With the @AuthenticationPrincipal we can inject the currently active user principal. With this representation, we can provide information that should be publicly available on the front end. The attribute names are not standardised and will vary between different providers. (This is the endpoint that is used for the Login and Logout context)
In static methods, you can access the security context to get the currently logged in user:
finalObject principal =SecurityContextHolder.getContext().getAuthentication().getPrincipal();if (principal instanceofOAuth2User oAuth2User) {return oAuth2User;}
This will serve the frontend at example.com and the backend at example.com/backend, just like we configured in the servlet context path.
Summary
We've created a Spring Boot backend that handles authentication with an OAuth2 provider. For login and logout, the user is redirected to the Spring Boot application. The session is maintained by the server-side backend and sends all the necessary information to the client browser, just as you would with any database information.
Login-Flow
Browsing the page at example.de and clicking on login
example.de/backend/login (which requires authorization and therefore redirects)
example.de/backend/oauth2/authorization/discord (starts the authorization process)
discord.com/oauth2/authorize
example.de/backend/login/oauth2/code/discord (callback with oauth code)
example.de/backend/login (back to the starting point)
Logout-Flow
Browsing the page at example.de and clicking on logout
example.de/backend/logout (Does the Front-Channel logout)