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.
Tech Stack
Backend: Spring Boot, Spring Security, Lombok
Frontend: React, react-router-dom, axios, @tanstack/react-query
OAuth2 Configuration
A great place to start is the official spring guide: https://spring.io/guides/tutorials/spring-boot-oauth2
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.
spring.security.oauth2.client.registration.discord.client-id=redacted
spring.security.oauth2.client.registration.discord.client-secret=redacted
spring.security.oauth2.client.registration.discord.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.discord.scope=identify
spring.security.oauth2.client.registration.discord.redirect-uri={baseUrl}/{action}/oauth2/code/{registrationId}
spring.security.oauth2.client.registration.discord.client-name=redacted
spring.security.oauth2.client.provider.discord.authorization-uri=https://discord.com/oauth2/authorize?prompt=none
spring.security.oauth2.client.provider.discord.token-uri=https://discord.com/api/oauth2/token
spring.security.oauth2.client.provider.discord.user-info-uri=https://discord.com/api/users/@me
spring.security.oauth2.client.provider.discord.user-name-attribute=username
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.
@Entity
@Table(name = "discord_user", uniqueConstraints = {@UniqueConstraint(columnNames = {"id"})})
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
public class User {
@Id
@Column(name = "id", nullable = false, unique = true, updatable = false)
protected long id;
}
If you're using Postgres as your database, don't try to name your table user
. This is a reserved keyword.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class OAuth2EndpointConfig {
private final GuildUsersService guildUsersService;
private final UserService userService;
@Bean
SecurityFilterChain oAuthUserFilterChain(HttpSecurity http) throws Exception {
http
.logout(logout -> logout
.logoutSuccessUrl("/startpage")
.logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
.addLogoutHandler(new HeaderWriterLogoutHandler(new ClearSiteDataHeaderWriter(COOKIES)))
)
.oauth2Login(login -> login
.loginPage("/oauth2/authorization/discord")
.defaultSuccessUrl("/startpage")
.userInfoEndpoint(userInfo -> userInfo
.userService(oAuthUserService())
)
);
return http.build();
}
@Bean
CookieSameSiteSupplier sameSiteSupplier() {
// Force JSESSIONID cookie to be SameSite=Lax
return CookieSameSiteSupplier.ofLax();
}
@Bean
OAuth2UserService<OAuth2UserRequest, OAuth2User> oAuthUserService() {
return new CustomOAuth2UserService(guildUsersService, userService);
}
}
L11: Configure the logout process.
L12: Where to redirect after successful logout.
L13: The endpoint that triggers the logout.
L14: When the user logs out, instruct the browser to delete any cookies sent by the site (https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Clear-Site-Data). In our case this is the
XSRF-TOKEN
and if you're using Tomcat for example, this is theJSESSIONID
.
L17: Configure the login process.
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 theCustomOauth2UserService
here. You may reference the original here.
CORS
@Configuration
@RequiredArgsConstructor
@Profile("dev")
public class WebMvcConfig implements WebMvcConfigurer {
@Value("${server.cors.allowed-origins}")
private String[] allowedOrigins;
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedMethods(GET.name(), POST.name(), PUT.name(), DELETE.name())
.allowedOrigins(allowedOrigins)
.allowCredentials(true);
}
}
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.
server.cors.allowed-origins=http://localhost:3000
Recommended optionals
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.
@Controller
@RequiredArgsConstructor
@RequestMapping("/login")
public class LoginWebController {
private final RedirectService redirectService;
@GetMapping
@PreAuthorize(HAS_ROLE_EVERYONE)
public RedirectView login(@RequestParam String redirectUrl) {
return new RedirectView(redirectService.redirectTo(redirectUrl));
}
}
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.
public interface RedirectService {
/**
* Creates an absolute path for redirection to the frontend. To do this, the servlet context path is removed
*
* @param redirectPath relative path
*/
String redirectTo(String redirectPath);
}
@Service
@Profile("!dev")
public class RedirectServiceImpl implements RedirectService {
@Value("#{servletContext.contextPath}")
private String servletContextPath;
@Override
public String redirectTo(String redirectPath) {
return ServletUriComponentsBuilder.fromCurrentContextPath().toUriString()
.replace(servletContextPath, "")
+ 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.
@Service
@Profile("dev")
public class RedirectServiceDevImpl implements RedirectService {
@Value("#{servletContext.contextPath}")
private String servletContextPath;
@Override
public String redirectTo(String redirectPath) {
return ServletUriComponentsBuilder.fromCurrentContextPath().port(3000).toUriString()
.replace(servletContextPath, "")
+ redirectPath;
}
}
The only difference for dev mode is the addition of the frontend port, since we won't be serving it locally from the same domain and port combination.
From Spring View to SPA
But how do we get back to our spa when the defaultSuccessUrl or logoutSuccessUrl is opened? Now let's create the controller to do this.
@Controller
@RequiredArgsConstructor
public class RedirectController {
private final RedirectService redirectService;
@GetMapping("/startpage") //OAuth2EndpointConfig logoutSuccessUrl
public RedirectView redirectToStartpage() {
return new RedirectView(redirectService.redirectTo("/startpage"));
}
}
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.
const routes: RoutesObject[] = [
{
path: '/',
element: <span>Hello World!</span>,
},
{
path: '/startpage',
element: <span>Start page</span>,
}
];
export function App(): JSX.Element {
const router = createBrowserRouter(routes);
return (
<RouterProvider router={router}/>
);
}
API Resolver
To access the backend API through the user's browser, we need a similar URL resolver to the RedirectService.
export function getBackendUrl(): string {
let hostname = window.location.hostname;
if (import.meta.env.DEV) {
hostname = `${hostname}:8080`;
}
return `${window.location.protocol}//${hostname}/backend`;
}
This directly matches our spring application's additional configuration. Again, in dev mode, we need to set the port.
server.port=8080
server.servlet.context-path=/backend
To make things easier, we'll use this backend API URL directly as the base URL for every axios request.
const backendClient = axios.create({
baseURL: getBackendUrl(),
withCredentials: import.meta.env.DEV ? true : undefined,
withXSRFToken: import.meta.env.DEV ? true : undefined,
});
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
).
Login and Logout
window.location.href = `${getBackendUrl()}/login?redirectUrl=${window.location.pathname}`;
window.location.href = `${getBackendUrl()}/logout
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.
Using the session
Who is logged in
@RestController
@RequestMapping("/authentication")
public class AuthenticationController {
@GetMapping
public DiscordUserDto getAuthenticatedUser(@AuthenticationPrincipal OAuth2User oAuth2User) {
return DiscordUserAssembler.toDto(oAuth2User);
}
}
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:
final Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (principal instanceof OAuth2User oAuth2User) {
return oAuth2User;
}
Require authentication on a route
export function RequireAuth(props: Readonly<{children: ReactNode}>): JSX.Element {
const {children} = props;
const {user, login} = useAuth();
if (user === undefined) return <></>;
return <>
{user ?
children
:
<OnMount do={login}/>
}
</>;
}
type RedirectProps = {
do: () => void;
};
function OnMount(props: Readonly<RedirectProps>): JSX.Element {
useEffect(() => {
props.do();
}, []);
return <></>;
}
Nginx setup
server {
root /frontend-project/dist;
location / {
try_files $uri /index.html;
}
location /backend {
proxy_pass http://127.0.0.1:8443;
proxy_set_header Host $http_host;
proxy_redirect http:// https://;
proxy_http_version 1.1;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
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 loginexample.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 logoutexample.de/backend/logout
(Does the Front-Channel logout)Redirect to the logoutSuccessUrl
Last updated
Was this helpful?