1. Overview
In this tutorial, we’ll implement the OAuth2 Backend for Frontend (BFF) pattern with Spring Cloud Gateway and spring-addons to consume a stateless REST API from three different single-page applications (Angular, React, and Vue).
When inspecting with a debugging tool, we won’t find any Bearer tokens on any of the major websites known for using OAuth2 (Google, Facebook, Github, or LinkedIn). Why is that?
According to security experts, we should not configure applications running on user devices as “public” OAuth2 clients, even with PKCE. The recommended alternative is to authorize both mobile and web apps with sessions on a BFF running on a server we trust.
We’ll see here how easily a Single Page Application (SPA) can consume a REST API through an OAuth2 BFF. We’ll also learn that existing resource servers (stateless REST APIs authorized with Bearer access tokens) require no modifications.
2. OAuth2 Backend for Frontend Pattern
Before diving into the implementation, let’s explore what an OAuth2 BFF is, what it brings, and at what price.
2.1. Definition
A Backend for Frontend is a middleware between a frontend and REST APIs, which can have different purposes. Here, we are interested in the OAuth2 BFF, which bridges between request authorization using a session cookie (with the frontend) and authorization using a Bearer token (as expected by resource servers). Its responsibilities are:
- Driving the authorization code and refresh token flows using a “confidential” OAuth2 client
- Maintaining sessions and storing tokens in them
- Replacing the session cookie with the access token in session before forwarding a request from the frontend to a resource server
2.2. Benefits Over Public OAuth2 Clients
The main added value is safety:
- With the BFF running on a server we trust, the authorization server token endpoint can be protected with a secret and firewall rules to allow only requests from our backend. This greatly reduces the risk that tokens are issued to malicious clients.
- Tokens are kept on the server (in session), which prevents them from being stolen on end-user devices by malicious programs. Usage of session cookies requires protection against CSRF, but cookies can be flagged with HttpOnly, Secure, and SameSite, in which case the cookie protection on the device is enforced by the browser itself. As a comparison, a SPA configured as a public client needs access to tokens, and we have to be very careful with how these tokens are stored: If a malicious program manages to read an access token, the consequences can be disastrous for the user. It’s even worse in the case of a refresh token as the identity usurpation can run for a very long period.
The other benefit is the complete control it gives over user sessions and the ability to instantly revoke access. As a reminder, JSON Web Tokens (JWTs) can’t be invalidated, and we can hardly delete tokens stored on end-user devices when terminating sessions on the server. If we send a JWT access token over the network, all we can do is wait for it to expire, as access to resource servers will continue to be authorized until then. But, if tokens never leave the backend, then we can delete them with the user session on the BFF, immediately revoking access to resources.
2.3. Cost
A BFF is an additional layer in the system, and it’s on the critical path. In production, this implies a little more resources and a little more latency. It will require some monitoring, too.
Also, the resource servers behind the BFF can (and should) be stateless, but the OAuth2 BFF itself needs sessions and this requires specific actions to make it scalable and fault-tolerant.
We can easily package Spring Cloud Gateway into a native image. This makes it super lightweight and bootable in a fraction of a second, but there’s always a limit to the traffic a single instance can absorb. When traffic increases, we’ll have to share the session between BFF instances. The Spring Session project would be of great help for that. Another option would be using a smart proxy to route all requests from a given device to the same BFF instance.
2.4. Choice of an Implementation
Some frameworks implement the OAuth2 BFF pattern without communicating explicitly about it or calling it that way. This is the case for instance of the NextAuth library, which uses server components to implement OAuth2 (a confidential client in a Node instance on the server). This is enough to benefit from the safety of the OAuth2 BFF pattern.
But because of the Spring ecosystem, there are few solutions as handy as Spring Cloud Gateway when monitoring, scalability, and fault tolerance matter:
- spring-boot-starter-actuator dependency provides powerful auditing features.
- Spring Session is a rather simple solution for distributed sessions.
- spring-boot-starter-oauth2-client and oauth2Login() handle the authorization code and refresh token flows. It also stores tokens in the session.
- The TokenRelay= filter replaces the session cookie with the access token in the session when forwarding requests from the frontend to a resource server.
3. Architecture
So far, we listed quite a few services: frontends (SPAs), REST API, BFF, and authorization server. Let’s have a look at how these make a coherent system.
3.1. System Overview
Here is a representation of services, ports, and path prefixes we’ll use with the main profile:
Two points are important to note from this schema:
- From the perspective of the end-user device, there’s a single point of contact for at least the BFF and the SPA assets: a reverse-proxy.
- The resource server is accessed through the BFF.
As we’ll see later, serving the authorization server through the reverse proxy is optional.
When going to a production-like environment, we could use (sub)domains instead of path prefixes to make a distinction between SPAs.
3.2. Quick Start
The companion repo contains a build script to build and start docker images for each of the services described above.
To get everything up and running, we should ensure that:
- A JDK between 17 and 21 is on the path. We can run java –version to check this.
- Docker Desktop is installed and running.
- The latest node LTS is on the path (nvm or nvm-windows can be of great help for that).
We can then run the following shell script (on Windows, we might use Git bash):
git clone https://github.com/eugenp/tutorials.git
cd tutorials/spring-security-modules/spring-security-oauth2-bff/
sh ./build.sh
In the next sections, we’ll see how to replace each of the containers with something at hand.
4. BFF Implementation With Spring Cloud Gateway and spring-addons-starter-oidc
First, using our IDE or https://start.spring.io/, we create a new Spring Boot project named bff with Reactive Gateway and OAuth2 client as dependencies.
Then, we rename src/main/resources/application.properties to src/main/resources/application.yml.
Last, we’ll add spring-addons-starter-oidc to our dependencies:
<dependency>
<groupId>com.c4-soft.springaddons</groupId>
<artifactId>spring-addons-starter-oidc</artifactId>
<version>7.7.0</version>
</dependency>
4.1. Re-Used Properties
Let’s start with a few constants in application.yml that will help us in other sections and when needing to override some values on the command line or IDE launch configuration:
scheme: http
hostname: localhost
reverse-proxy-port: 7080
reverse-proxy-uri: ${scheme}://${hostname}:${reverse-proxy-port}
authorization-server-prefix: /auth
issuer: ${reverse-proxy-uri}${authorization-server-prefix}/realms/baeldung
client-id: baeldung-confidential
client-secret: secret
username-claim-json-path: $.preferred_username
authorities-json-path: $.realm_access.roles
bff-port: 7081
bff-prefix: /bff
resource-server-port: 7084
audience:
Of course, we’ll have to override the value of client-secret with, for instance, an environment variable, a command-line argument, or an IDE launch configuration.
4.2. Server Properties
Now come the usual server properties:
server:
port: ${bff-port}
4.3. Spring Cloud Gateway Routing
As we have a single resource server behind the gateway, we need only one route definition:
spring:
cloud:
gateway:
routes:
- id: bff
uri: ${scheme}://${hostname}:${resource-server-port}
predicates:
- Path=/api/**
filters:
- DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin
- TokenRelay=
- SaveSession
- StripPrefix=1
The most important parts are the SaveSession and TokenRelay= which form a cornerstone for the OAuth2 BFF pattern implementation. The first ensures that the session is persisted, with the tokens fetched by oauth2Login(), and the second replaces the session cookie with the access token in session when routing a request.
The StripPrefix=1 filter removes the /api prefix from the path when routing a request. Notably, the /bff prefix was already stripped during the reverse-proxy routing. As a consequence, a request sent from the frontend to /bff/api/me lands as /me on the resource server.
4.4. Spring Security
We can now get into configuring OAuth2 client security with the standard Boot properties:
spring:
security:
oauth2:
client:
provider:
baeldung:
issuer-uri: ${issuer}
registration:
baeldung:
provider: baeldung
authorization-grant-type: authorization_code
client-id: ${client-id}
client-secret: ${client-secret}
scope: openid,profile,email,offline_access
There’s really nothing special here, just a standard OpenID Provider declaration with a single registration using an authorization code and refresh token.
4.5. spring-addons-starter-oidc
To complete the configuration, let’s tune Spring Security with spring-addons-starter-oidc:
com:
c4-soft:
springaddons:
oidc:
# Trusted OpenID Providers configuration (with authorities mapping)
ops:
- iss: ${issuer}
authorities:
- path: ${authorities-json-path}
aud: ${audience}
# SecurityFilterChain with oauth2Login() (sessions and CSRF protection enabled)
client:
client-uri: ${reverse-proxy-uri}${bff-prefix}
security-matchers:
- /api/**
- /login/**
- /oauth2/**
- /logout
permit-all:
- /api/**
- /login/**
- /oauth2/**
csrf: cookie-accessible-from-js
oauth2-redirections:
rp-initiated-logout: ACCEPTED
# SecurityFilterChain with oauth2ResourceServer() (sessions and CSRF protection disabled)
resourceserver:
permit-all:
- /login-options
- /error
- /actuator/health/readiness
- /actuator/health/liveness
Let’s understand the three main sections:
- ops, with OpenID Provider(s) specific values: This enables us to specify the JSON path of the claims to convert to Spring authorities (with optional prefixes and case transformation for each). If the aud property is not empty, spring-addons adds an audience validator to the JWT decoder(s).
- client: When security-matchers are not empty, this section triggers the creation of a SecurityFilterChain bean with oauth2Login(). Here, with the client-uri property, we force the usage of the reverse-proxy URI as a base for all redirections (instead of the BFF internal URI). Also, as we are using SPAs, we ask the BFF to expose the CSRF token in a cookie accessible to Javascript. Last, to prevent CORS errors, we ask that the BFF respond to the RP-Initiated Logout with 201 status (instead of 3xx), which gives SPAs the ability to intercept this response and ask the browser to process it in a request with a new origin.
- resourceserver: This requests a second SecurityFilterChain bean with oauth2ResourceServer(). This filter chain having an @Order with the lowest precedence will process all of the requests that weren’t matched by the security matchers from the client SecurityFilterChain. We use it for all resources for which a session is not desirable: endpoints that aren’t involved in login or routing with TokenRelay.
4.6. /login-options Endpoint
The BFF is where we define login configuration: Spring OAuth2 client registration(s) with authorization code. To avoid configuration duplication in each SPA (and possible inconsistencies), we’ll host on the BFF a REST endpoint exposing the login option(s) it supports for users.
For that, all we have to do is expose a @RestController with a single endpoint returning a payload built from configuration properties:
@RestController
public class LoginOptionsController {
private final List<LoginOptionDto> loginOptions;
public LoginOptionsController(OAuth2ClientProperties clientProps, SpringAddonsOidcProperties addonsProperties) {
final var clientAuthority = addonsProperties.getClient()
.getClientUri()
.getAuthority();
this.loginOptions = clientProps.getRegistration()
.entrySet()
.stream()
.filter(e -> "authorization_code".equals(e.getValue().getAuthorizationGrantType()))
.map(e -> {
final var label = e.getValue().getProvider();
final var loginUri = "%s/oauth2/authorization/%s".formatted(addonsProperties.getClient().getClientUri(), e.getKey());
final var providerId = clientProps.getRegistration()
.get(e.getKey())
.getProvider();
final var providerIssuerAuthority = URI.create(clientProps.getProvider()
.get(providerId)
.getIssuerUri())
.getAuthority();
return new LoginOptionDto(label, loginUri, Objects.equals(clientAuthority, providerIssuerAuthority));
})
.toList();
}
@GetMapping(path = "/login-options", produces = MediaType.APPLICATION_JSON_VALUE)
public Mono<List<LoginOptionDto>> getLoginOptions() throws URISyntaxException {
return Mono.just(this.loginOptions);
}
public static record LoginOptionDto(@NotEmpty String label, @NotEmpty String loginUri, boolean isSameAuthority) {
}
}
We can now stop the baeldung-bff.bff docker container and run the BFF application, carefully providing on the command line or run configuration:
- hostname: the value of the hostname command or HOSTNAME environment variable, transformed to lowercase
- client-secret: the value of the secret for the baeldung-confidential client declared in the authorization-server (“secret” unless explicitly changed)
4.7. Non-Standard RP-Initiated Logout
RP-Initiated Logout is part of the OpenID standard, but some providers don’t implement it strictly. This is the case of Auth0 and Amazon Cognito, for instance, which don’t provide an end_session endpoint in their OpenID configuration and use their own query parameters for logout.
spring-addons-starter-oidc supports such logout endpoints that “almost” comply with the standard. The BFF configuration in the companion project contains profiles with the required configurations:
---
spring:
config:
activate:
on-profile: cognito
issuer: https://cognito-idp.us-west-2.amazonaws.com/us-west-2_RzhmgLwjl
client-id: 12olioff63qklfe9nio746es9f
client-secret: change-me
username-claim-json-path: username
authorities-json-path: $.cognito:groups
com:
c4-soft:
springaddons:
oidc:
client:
oauth2-logout:
baeldung:
uri: https://spring-addons.auth.us-west-2.amazoncognito.com/logout
client-id-request-param: client_id
post-logout-uri-request-param: logout_uri
---
spring:
config:
activate:
on-profile: auth0
issuer: https://dev-ch4mpy.eu.auth0.com/
client-id: yWgZDRJLAksXta8BoudYfkF5kus2zv2Q
client-secret: change-me
username-claim-json-path: $['https://c4-soft.com/user']['name']
authorities-json-path: $['https://c4-soft.com/user']['roles']
audience: bff.baeldung.com
com:
c4-soft:
springaddons:
oidc:
client:
authorization-params:
baeldung:
audience: ${audience}
oauth2-logout:
baeldung:
uri: ${issuer}v2/logout
client-id-request-param: client_id
post-logout-uri-request-param: returnTo
In the snippet above, baeldung is a reference to the client registration in Spring Boot properties. If we’d used a different key in spring.security.oauth2.client.registration, we’d have to use it here, too.
In addition to the required property overrides, we can note in the second profile the specification for an additional request parameter when we send an authorization request to Auth0: audience.
5. Resource Server With spring-addons-starter-oidc
Our need for this system is simple: a stateless REST API authorized with JWT access tokens, exposing a single endpoint to reflect some user info contained in the token (or a payload with empty values if the request isn’t authorized).
For that, we’ll create a new Spring Boot project named resource-server with Spring Web and OAuth2 Resource Server as dependencies.
Then, we rename src/main/resources/application.properties to src/main/resources/application.yml.
Last, we’ll add spring-addons-starter-oidc to our dependencies:
<dependency>
<groupId>com.c4-soft.springaddons</groupId>
<artifactId>spring-addons-starter-oidc</artifactId>
<version>7.7.0</version>
</dependency>
5.1. Configuration
Let’s look at the properties we need for our resource server:
scheme: http
hostname: localhost
reverse-proxy-port: 7080
reverse-proxy-uri: ${scheme}://${hostname}:${reverse-proxy-port}
authorization-server-prefix: /auth
issuer: ${reverse-proxy-uri}${authorization-server-prefix}/realms/baeldung
username-claim-json-path: $.preferred_username
authorities-json-path: $.realm_access.roles
resource-server-port: 7084
audience:
server:
port: ${resource-server-port}
com:
c4-soft:
springaddons:
oidc:
ops:
- iss: ${issuer}
username-claim: ${username-claim-json-path}
authorities:
- path: ${authorities-json-path}
aud: ${audience}
resourceserver:
permit-all:
- /me
Thanks to spring-addons-starter-oidc, this is enough to declare a stateless resource server with:
- Authorities mapping from a claim of our choice (realm_access.roles in the case of Keycloak with realm roles)
- Making /me accessible to anonymous requests
The application.yaml in the companion repo contains profiles for other OpenID Providers using other private claims for roles.
5.2. @RestController
Let’s implement a REST endpoint returning some data from the Authentication in the security context (if any):
@RestController
public class MeController {
@GetMapping("/me")
public UserInfoDto getMe(Authentication auth) {
if (auth instanceof JwtAuthenticationToken jwtAuth) {
final var email = (String) jwtAuth.getTokenAttributes()
.getOrDefault(StandardClaimNames.EMAIL, "");
final var roles = auth.getAuthorities()
.stream()
.map(GrantedAuthority::getAuthority)
.toList();
final var exp = Optional.ofNullable(jwtAuth.getTokenAttributes()
.get(JwtClaimNames.EXP)).map(expClaim -> {
if(expClaim instanceof Long lexp) {
return lexp;
}
if(expClaim instanceof Instant iexp) {
return iexp.getEpochSecond();
}
if(expClaim instanceof Date dexp) {
return dexp.toInstant().getEpochSecond();
}
return Long.MAX_VALUE;
}).orElse(Long.MAX_VALUE);
return new UserInfoDto(auth.getName(), email, roles, exp);
}
return UserInfoDto.ANONYMOUS;
}
/**
* @param username a unique identifier for the resource owner in the token (sub claim by default)
* @param email OpenID email claim
* @param roles Spring authorities resolved for the authentication in the security context
* @param exp seconds from 1970-01-01T00:00:00Z UTC until the specified UTC date/time when the access token expires
*/
public static record UserInfoDto(String username, String email, List<String> roles, Long exp) {
public static final UserInfoDto ANONYMOUS = new UserInfoDto("", "", List.of(), Long.MAX_VALUE);
}
}
Just as we did for the BFF, we can now stop the baeldung-bff.resource-server docker container, providing hostname on the command line or run configuration.
5.3. Resource Server Multi-Tenancy
What if the frontends consuming our REST API don’t all authorize their users on the same authorization server or realm (or if they offer a choice of authorization servers)?
With spring-security-starter-oidc, this is dead simple: com.c4-soft.springaddons.oidc.ops configuration property is an array, and we can add as many issuers as we trust, each with its mapping for user name and authorities. A valid token issued by any of these issuers will be accepted by our resource server and roles correctly mapped to Spring authorities.
6. SPAs
Because there are some differences between the frameworks used to connect SPAs to an OAuth2 BFF, we’ll cover the three major ones: Angular, React, and Vue.
But, creating SPAs is out of the scope of this article. Hereafter, we’ll focus only on what it takes for a web application to log users in and out on an OAuth2 BFF and query a REST API behind it. Please refer to the companion repo for complete implementations.
An effort was made for the apps to have the same structure:
- Two routes to demo how the current one can be restored after authentication.
- A Login component offers a choice of login experience if both iframe and default are available. It also handles iframe display status or redirection to the authorization server.
- A Logout component sends a POST request to the BFF /logout endpoint and then redirects to the authorization server for RP-Initiated Logout
- A UserService fetches current user data from the resource server through the BFF. It also holds some logic for scheduling a refresh of this data just before the access token on the BFF expires.
There is however a difference in the way the current user data is managed because of the very different way frameworks handle state:
- In an Angular app, the UserService is a singleton managing current user with a BehaviorSubject.
- In a React app, we used createContext in *app/*layout.tsx to expose the current user to all components, and useContext wherever we need to access it.
- In a Vue app, the UserService is a singleton (instantiated in main.ts) managing the current user with a ref.
6.1. Running SPAs in Companion Repo
The first thing to do is to cd into the folder of the project we want to run.
Then, we should run “npm install” to pull all required npm packages.
Lastly, after we stopped the corresponding docker container and depending on the framework:
- Angular: run “npm run start” and open http://{hostname}:7080/angular-ui/
- Vue: run “npm run dev” and open http://{hostname}:7080/vue-ui/
- React (Next.js): run “npm run dev” and open http://{hostname}:7080/react-ui/
We should be careful to use only URLs pointing to the reverse proxy and not to the SPAs dev-servers (http://{hostname}:7080, not http://{hostname}:4201, http://{hostname}:4202 or http://{hostname}:4203).
6.2. User Service
The responsibility for the UserService is to:
- Define the user representations (internal and DTO).
- Expose a function to fetch user data from the resource server through the BFF.
- Schedule a refresh() call just before the access token expires (keep the session alive).
6.3. Login
As we already saw, when possible, we provide two different login experiences:
- The user is redirected to the authorization server using the current browser tab (the SPA temporarily “exits”). This is the default behavior and is always available.
- Authorization server forms are displayed in an iframe inside the SPA, which requires SameOrigin for the SPA and the authorization server and, as so, works only when the BFF and resource server run with the default profile (with Keycloak).
The logic is implemented by a Login component which displays a drop-down to select the login experience (iframe or default) and a button.
Login options are fetched from the BFF when the component is initialized. In the case of this tutorial, we expect only one option, so we pick only the 1st entry in the response payload.
When the user clicks the Login button, what happens depends on the chosen login experience:
- If iframe is selected, the iframe source is set to the login URI, and the modal div containing the iframe is displayed.
- Otherwise, the window.location.href is set to the login URI, which “exits” the SPA and sets the current tab with a brand-new origin.
When the user selects the iframe login experience, we register an event listener for the iframe load events to check if the user authentication is successful and hide the modal. This call-back runs each time a redirection happens in the iframe.
Last, we can note how the SPAs add a post_login_success_uri request parameter to the authorization code flow initiation request. spring-addons-starter-oidc saves the value of this parameter in session and, after the authorization code is exchanged for tokens, uses it to build the redirection URI returned to the frontend.
6.4. Logout
The logout button and associated logic are handled by the Logout component.
By default, the Spring /logout endpoint expects a POST request and, as any request modifying state on a server with sessions, it should contain a CSRF token. Angular and React handle transparently CSRF cookies flagged with http-only=false and request headers. But we have to manually read the XSRF-TOKEN cookie and set the X-XSRF-TOKEN header in Vue for every POST, PUT, PATCH, and DELETE requests. We should also always refer to the documentation of the frontend framework we pick as there could be some subtle roadblocks. Angular, for instance, will set the X-XSRF-TOKEN header for us only but only for URLs without an authority (we should query /bff/api/me instead of http://localhost:7080/bff/api/me, even if the window location is http://localhost:7080/angular-ui/).
When involving a Spring OAuth2 client, the RP-Initiated Logout happens in two requests:
- First, a POST request is sent to the Spring OAuth2 client which closes its own session.
- The response of the 1st request has a Location header with a URI on the authorization server to close the other session that the user has there.
The default Spring behavior is to use 302 status for the 1st request, which makes the browser follow automatically to the authorization server, keeping the same origin. To avoid CORS errors, we configured the BFF to use a status in the 2xx field. This requires the SPA to manually follow the redirection but gives it the opportunity to do it with window.location.href (with a new origin).
Last, we can note how the post-logout URI is provided by SPAs using a X-POST-LOGOUT-SUCCESS-URI header with the logout request. spring-addons-starter-oidc uses the value of this header to insert a request parameter in the URI of the logout request from the authorization server.
6.5. Client Multi-Tenancy
In the companion project, there is a single OAuth2 client registration with an authorization code. But what if we had more? This might happen for instance if we share a BFF across several frontends, some having distinct issuer or scope.
The user should be prompted to choose only between OpenID Providers he can authenticate on, and in many cases, we can filter the login options.
Here are a few samples of situations where we can drastically shrink the number of possible choices, ideally to one so that the user isn’t prompted for a choice:
- The SPA is configured with a specific option to use.
- There are several reverse-proxies and each can set something like a header with the option to use.
- Some technical info, like the IP of the frontend device, can tell us that a user should be authorized here or there.
In such situations, we have two choices:
- Send the filtering criteria with the request to /login-options and filter in the BFF controller.
- Filter /login-options response inside the frontend.
7. Back-Channel Logout
What if, in an SSO configuration, a user with an opened session on our BFF logs out using another OAuth2 client?
In OIDC, the Back-Channel Logout specification was made for such scenarios: when declaring a client on an authorization server, we can register a URL to be called when a user logs out.
Because the BFF runs on a server, it can expose an endpoint to be notified of such log-out events. Since version 6.2, Spring Security supports Back-Channel Logout and spring-addons-starter-oidc exposes a flag to enable it.
Once the session ends on the BFF with Back-Channel Logout, the requests from the frontend to the resource server(s) won’t be authorized anymore (even before token expiration). So for a perfect user experience, when activating Back-Channel Logout on a BFF, we should probably also add a mechanism like WebSockets to notify frontends of user status changes.
8. Reverse Proxy
We need the same origin for a SPA and its BFF because:
- The requests are authorized with session cookies between the frontend and the BFF.
- Spring session cookies are flagged with SameSite=Lax.
For that, we’ll use a reverse proxy as a single contact point for browsers. But there are many different solutions for implementing such a reverse proxy and our choice will depend on the context:
- In Docker, we use a Nginx container.
- On K8s, we’d probably configure an Ingress.
- When working from our IDE, we might prefer a Spring Cloud Gateway instance. If the number of running services matters, we could even use some extra routes on the Gateway instance used as BFF instead of using a dedicated one as done in this article.
8.1. Whether to Hide the Authorization Server Behind the Reverse Proxy
For security reasons, an authorization server should always set the X-Frame-Options header. As Keycloak allows to set it to SAMEORIGIN, if the authorization server and the SPA share the same origin, then it’s possible to display Keycloak login & registration forms in an iframe embedded in this SPA.
From the end-user perspective, it’s probably a better experience to stay in the same web app with authorization forms displayed in a modal, rather than being redirected back and forth between the SPA and an authorization server.
On the other hand, Single Sign-On (SSO) relies on cookies flagged with SameOrigin. As a consequence, for two SPAs to benefit from Single Sign-On they should not only authenticate users on the same authorization server but also use the same authority for it (both https://appa.net and https://appy.net authenticate users on https://sso.net).
A solution to match both conditions is using the same origin for all SPAs and the authorization server, with URIs like:
This is the option we’ll use when working with Keycloak, but sharing the same origin between the SPAs and the authorization server isn’t a requirement for the BFF pattern to work, only sharing the same origin between the SPAs and the BFF is.
The projects in the companion repo are preconfigured to use Amazon Cognito and Auth0 with their origin (no smart proxy rewriting redirection URLs on the fly). For this reason, login from an iframe is available only when using the default profile (with Keycloak).
8.2. Implementation with Spring Cloud Gateway
First, using our IDE or https://start.spring.io/, we create a new Spring Boot project named reverse-proxy with Reactive Gateway as a dependency.
Then we rename src/main/resources/application.properties to src/main/resources/application.yml.
We should then define the routing properties for Spring Cloud Gateway:
# Custom properties to ease configuration overrides
# on command-line or IDE launch configurations
scheme: http
hostname: localhost
reverse-proxy-port: 7080
angular-port: 4201
angular-prefix: /angular-ui
angular-uri: http://${hostname}:${angular-port}${angular-prefix}
vue-port: 4202
vue-prefix: /vue-ui
vue-uri: http://${hostname}:${vue-port}${vue-prefix}
react-port: 4203
react-prefix: /react-ui
react-uri: http://${hostname}:${react-port}${react-prefix}
authorization-server-port: 8080
authorization-server-prefix: /auth
authorization-server-uri: ${scheme}://${hostname}:${authorization-server-port}${authorization-server-prefix}
bff-port: 7081
bff-prefix: /bff
bff-uri: ${scheme}://${hostname}:${bff-port}
server:
port: ${reverse-proxy-port}
spring:
cloud:
gateway:
default-filters:
- DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin
routes:
# SPAs assets
- id: angular-ui
uri: ${angular-uri}
predicates:
- Path=${angular-prefix}/**
- id: vue-ui
uri: ${vue-uri}
predicates:
- Path=${vue-prefix}/**
- id: react-ui
uri: ${react-uri}
predicates:
- Path=${react-prefix}/**
# Authorization-server
- id: authorization-server
uri: ${authorization-server-uri}
predicates:
- Path=${authorization-server-prefix}/**
# BFF
- id: bff
uri: ${bff-uri}
predicates:
- Path=${bff-prefix}/**
filters:
- StripPrefix=1
We can now start the reverse proxy reverse-proxy (after we stop the docker container and provide hostname as a command-line argument or in run configuration).
9. Authorization Server
In the companion project on GitHub the default profile is designed for Keycloak but, thanks to spring-addons-starter-oidc, switching to any other OpenID Provider is just a matter of editing application.yml. The files provided in the companion project contain profiles to help us get started easily with Auth0 and Amazon Cognito.
Whatever OpenID Provider we choose, we should:
- Declare a confidential client
- Figure out the private claim(s) to use as the source for user roles
- Update BFF and resource server properties
10. Why Use spring-addons-starter-oidc?
All along this article, we modified quite a few default behaviors of both spring-boot-starter-oauth2-client and spring-boot-starter-oauth2-resource-server:
- Change OAuth2 redirect URIs to point to a reverse-proxy instead of the internal OAuth2 client.
- Give SPAs the control of where the user is redirected after login/logout.
- Expose CSRF token in a cookie accessible to JavaScript code running in a browser.
- Adapt to not exactly standard RP-Initiated Logout (Auth0 and Amazon Cognito for instance).
- Add optional parameters to the authorization request (Auth0 audience, for example).
- Change the HTTP status of OAuth2 redirections so that SPAs can choose how to follow the Location header.
- Register two distinct SecurityFilterChain beans with respectively oauth2Login() (with session-based security and CSRF protection) and oauth2ResourceServer() (stateless, with token-based security) to secure different groups of resources.
- Define which endpoints are accessible to anonymous.
- On resource servers, accept tokens issued by more than just one OpenID Provider.
- Add an audience validator to JWT decoder(s).
- Map authorities from any claim(s) (and add a prefix or force upper/lower case).
This usually requires quite a lot of Java code and a deep knowledge of Spring Security. But here, we did it with just application properties and could use the guidance of our IDE auto-completion!
We should refer to the starter README on GitHub for a complete list of features, auto-configuration tuning, and defaults overrides.
11. Conclusion
In this tutorial, we saw how to implement the OAuth2 Backend for Frontend pattern with Spring Cloud Gateway and spring-addons.
We also saw:
- Why we should favor this solution over configuring SPAs as “public” OAuth2 clients.
- Introducing a BFF has little impact on the SPA itself.
- This pattern changes nothing at all on resource servers.
- Because we use a server-side OAuth2 client, we can get complete control of user sessions, even in SSO configurations, thanks to Back-Channel Logout.
Last, we started to explore how convenient spring-addons-starter-oidc can be to configure, with just properties, what usually requires quite a lot of Java configuration.
As usual, all the code implementations are available over on GitHub.