OAuth2/OpenID for Spring Boot 3 and SPA
Take an in-depth look into user authentication and role-based access control in a Single Page Application with OIDC and a Spring Boot 3 backend.
Join the DZone community and get the full member experience.
Join For FreeConfiguring applications as public OAuth2 clients is now discouraged. For this reason, we'll use an architecture in which the requests from the frontend are authorized with sessions on a middleware on the server: a Backend For Frontend. As opposed to SPAs and mobile apps, such a BFF can be a confidential client. Also, with such a configuration, tokens stay safe on servers we trust.
Over the last 18 months, I have answered many questions (maybe 500) about OAuth2 and Spring. What I learned from this experience is that OAuth2 misconfigurations in Spring applications are mostly due to an incomplete or outdated understanding of OAuth2. So, I'll start this article with an OAuth2 refresher. Taking the time to read it could save you days of frustration with an application not behaving the way you expect, or prevent you from introducing security breaches.
The source code in this article is taken from this tutorial.
What Are OAuth2 and OpenID?
OAuth2 is an authorization framework that lets third-party applications access a user's data without exposing login credentials. OpenID is a standard on top of OAuth2 that introduces ID tokens and quite a few standard claims. They work hand-in-hand to provide a comprehensive authentication process and verified identity sharing through a combination of access tokens and ID tokens.
Google, Facebook, GitHub, Office365, and many others use OAuth2/OpenID. Why is it so popular and superior to plain old login/password in each application?
- User experience: OAuth2 and OpenID ease the sharing of trusted identities across applications. This means that users authenticated once can access seamlessly all the applications trusting the same authorization server, just like when switching between Gmail, Drive, and YouTube, for instance.
- Improved security: User credentials are managed in one single place, which reduces the attack surface and reduces the chances a user database is leaked. Also, the resource owner credentials are exchanged at a low frequency and only between the resource owner user-agent and the authorization server. All requests to clients being authorized with session cookies and those to resource servers with Bearer tokens, the risk that resource owner credentials are leaked is reduced.
- Cost efficiency: Registration, login, logout, and user management are developed only once, which saves time and money. Also, on-premise and SaaS solutions are already available with many more features than your team could reasonably implement: login, user registration, API and UI for clients declaration and configuration, multi-factor authentication, identity federation for most common providers (Google, Facebook, GitHub, etc.), connectors to LDAP, OIDC compliance, etc. Learning to configure such tools is way faster, cheaper, and safer than reinventing the wheel.
- Scalability: Resource servers can be configured without sessions, which makes it scalable and fault tolerant: no need for requests from the same user-agent to be routed to the same resource server instance. Clients need sessions, but:
- The gateway we use as an OAuth2 client here is lightweight and has very few responsibilities (mostly routing and keeping tokens in session) => it will absorb a lot of traffic before needing an upscale.
- The gateway integrates very well with Spring Session, which offers a way to share sessions across instances.
- Even if a session is not shared and if a request from a given user-agent is routed to a gateway instance on which it does not have a session already, this session has a great chance to be created transparently (without the need for a new login as the user-agent should already have an opened session on the authorization server).
Just Enough OAuth2 Background
Actors
It is important to keep in mind that OAuth2 is not just a matter of a frontend and a backend: it defines 4 actors and it is essential to have a clear vision of what is what when you think of your application's architecture:
- Resource owner: Think of it as an end-user. Most frequently, this is a physical person, but it can be a web service authenticated with client credentials. In Spring Security, it is represented by an
Authentication
instance in the security context. - Resource server: An API (most frequently REST) responsible for serving resources; Requests to OAuth2 resource servers are authorized with a
Bearer
access token set asAuthorization
header. In Spring, the natural candidates are applications with@RestController
(or@Controller
with@ResponseBody
). - Client: A piece of software that needs to access resources on one or more resource servers; it does it in its own name (with client_credentials) or on behalf of an end-user for whom it has an access token (with authorization_code). Request to OAuth2 clients are authorized with sessions. Only requests from an OAuth2 client to an OAuth2 resource server are authorized with tokens. Note that according to the latest recommendations, such clients run on servers you trust, not in a browser or mobile app (more on that later).
- Authorization server: The server issuing and certifying identities (ID tokens) and identity delegation (access tokens: what an resource owner allowed a client to do on his behalf). Requests from the user browser to the authorization endpoint are authorized with sessions like all requests from a browser should be (more on that later).
We can deduce from the above that the SecurityFilterChain
configuration will be very different in our Spring applications depending on what it is:
- An OAuth2 client: With a security context built from the session, login, logout, and CSRF protection
- An OAuth2 resource server: With a security context built from access tokens, without login, logout, session, or CSRF protection
These security requirements are quite incompatible. In the cases where we need an application to be both a client with login and a resource server, we'll build distinct ordered filter-chains with securityMatcher
in each but the last one to define which filter-chain processes which requests. Learn more about securing REST API endpoints with Spring Security.
Reliable OAuth2 Clients
As neither JavaScript-based applications running in a browser (Angular, React, Vue, etc.) nor native mobile applications can keep a secret, they can only be configured as "public" clients. A former version of this article recommended such clients, and many other articles out there still do.
However, according to the latest recommendations, we should be using only "confidential" clients (clients with an ID and some sort of secret), which require it to be running on servers we trust.
For this reason, in this tutorial, the OAuth2 client will be a middleware on the server: spring-cloud-gateway
configured as an OAuth2 client to:
- Initiate the
authorization_code
flow (oauth2Login
in Spring) - Keep tokens in session
- Replace the session cookie with an
Authorization
header containing the access token in session before forwarding a request from the front end to a resource server
This kind of middleware is frequently called a backend for frontend (a.k.a. BFF).
Flows
There are quite a few, and you are likely to find some older articles referencing password
or implicit
flows, but those two were deprecated long ago for security reasons, and they should by no means be used in production anymore.
We should be using authorization_code
to identify users or client_credentials
when we need to authorize a request without the context of a user (in a script, for instance).
Authorization Code
This is probably the most useful one, but is not always mastered. Let's have a closer look at it:
- In OIDC environments, clients and resource servers fetch the authorization server configuration from a standard path to the OIDC Provider. This happens at startup or just before the first request is processed.
- When user authentication is needed, the front end "exits" to redirect the unauthorized user to the authorization server using the system browser. If the user already has an opened session on the authorization server, the login succeeds silently. Otherwise, the user is prompted for credentials, biometry MFA tokens, or whatever has been configured on the OP.
- Once the user is authenticated, the authorization-server redirects the user back to the client with a code to be used once. This redirection happens in the system browser used to initiate the
authorization_code
flow. - The client contacts authorization-server to exchange the
code
for an access token (and optionally ID and refresh tokens). - The frontend sends REST requests to the resource server by the intermediate of the OAuth2 client (which replaces the session cookie with an
Authorization
header containing aBearer
access token). - The resource server validates the access token (using the JWT public key fetched once or introspecting each token on the OP) and takes access-control decisions.
Client Credentials
The client sends the client ID and secret to the authorization server, which returns an access token to be used to authenticate the client itself (no user context). This must be limited to clients running on a server you trust (capable of actually keeping a secret "secret") and excludes all services running in a browser or a mobile app (code can be reverse-engineered to read secrets).
Tokens
A token represents a resource owner's identity and what the client can do on his behalf, pretty much like a paper proxy you could give to someone else to vote for you. It contains the following attributes at a minimum:
- Issuer: The authorization server that emitted the token (police officer or the like who certified the identities of people who gave and received proxy)
- Subject: A resource owner unique identifier (person who grants the proxy)
- Scope: What this token can be used for (did the resource owner grant a proxy for voting, managing a bank account, getting a parcel at the post office, etc.)
- Issued at: When was this token created (in seconds since epoch)
- Expiration: Until when can this token be used (in seconds since epoch)
JWT
A JWT is a JSON Web Token. It is used primarily as an access or ID token with OAuth2. JWTs can be validated on their own by a JWT decoder, which needs no more than the public signing key of the authorization server that issued the token.
Opaque Tokens
Opaque tokens can be used (any format, including JWT), but it requires introspection: clients and resource servers have to send a request to the authorization server to ensure the token is valid and get token "attributes" (equivalent to JWT "claims"). This process has serious performance costs compared to JWT decoding.
Access Token
This is a token to be sent by the client as a Bearer Authorization header in its requests to the resource server. Clients should not try to read access tokens; it just passes them around. An access token should be sent only to the authorization server that issued it (the value of the iss
claim) or to one of the resource servers listed as the audience (aud
claim): if leaked, a malicious client could act on behalf of the resource owner until the token expires.
Refresh Token
This token is to be sent by the client to the authorization server to get a new access token when it expires (or preferably just before). It should be sent only to the authorization server that issued it. If leaked, a malicious client can be issued new tokens and act on behalf of the resource owner for a very long time. So, it is the most sensible token, and clients have to be very careful with how it is stored and to which server it is sent.
ID Token
The ID token is part of the OpenID extension to OAuth2 and is a token to be used by the client to get user info.
Scope
Scope defines what the resource owner allows a client to do on his behalf (not what the resource owner is allowed to do in the system). You might think of it as a mask applied to resource owner resources before a client accesses it.
OpenID
This is a standard on top of OAuth2 with, among other things, standard claims. It eases the sharing of trusted identity data between applications.
OAuth2 and CSRF
The vector for CSRF attacks is sessions. Only resource servers can be insensible to CSRF attacks because it doesn't need sessions. OAuth2 clients and OAuth2 authorization servers need sessions, which makes them exposed to CSRF. As a rule of thumb, always make sure that CSRF protection is enabled in applications with any sort of login (oauth2Login
or Form
login).
OAuth2 and CORS
As we've seen, our application involves four different server processes: OAuth2 client, OAuth2 resource server, OAuth2 authorization server, and whatever serves SPA assets (Angular dev server, NGINX, etc.). OAuth2 flows redirect the user-agent from one to the other, which is likely to require some cross-origin configuration.
However, as we are serving both the front and back ends through a gateway, from the browser perspective, requests have the same origin for the client, resource server, and Angular assets. CORS configuration is needed only on the authorization server.
Configuring an Authorization Server
To proceed with the following tutorial, we'll need an OpenID authorization server with a confidential client and a few resource owners. For the sake of simplicity, we'll use a standalone Keycloak distribution powered by Quarkus. Instead, you could use any OIDC authorization server you already have at hand (Auth0, Okta, Amazon Cognito, etc.). Just adapt the issuer URI and the private claim to map authorities from the configuration below.
Server Configuration
If you don't have an SSL certificate for your host already, generate one (read the instructions carefully until the end). For the rest of this article, we'll consider that all servers are configured with SSL (Angular dev server, Spring Boot applications, and Keycloak). Please adapt all URI schemes in the following if you haven't configured your servers to use SSL.
Once you have decompressed the Keycloak archive, edit the conf/keycloak.conf
file:
http-port=8442
https-key-store-file=/path/to/self_signed.jks
https-key-store-password=change-me
https-port=8443
You're all set: just start with bin/kc.bat start-dev
or bin/kc.sh start-dev
and connect to https://localhost:8443.
Client
A single client will be enough to query our sample resource-server: spring-addons-confidential
will be created with client authentication and with "Standard flow." This client will be used by the BFF to authenticate users.
You'll have to set as a minimum on the gateway:
- Authorization-code callback endpoints as valid redirect URI:
https://localhost:8080/login/oauth2/code/*
- Gateway as allowed origin
https://localhost:8080
- UI URI on the gateway as post-logout redirect URI:
https://localhost:8080/ui/
Don't forget to save after you add the URIs.
Roles
We'll create a "Realm role" named NICE
.
Alternatively, you can define roles at the client level. If you're doing so, also enable the "client roles" mapper from Client details-> Client scopes -> spring-addons-confidential-dedicated -> Add mapper -> From predefined mappers.
Users
Let's create a few users:
brice
withNICE
roleigor
with no role
Spring Boot Resource Server
We'll see that with the help of Spring Boot, we can build a secured resource server in minutes, including security rules unit testing.
Requirements
- Spring Boot after
3.1.0
: withoutWebSecurityConfigurerAdapter - User authorities should be mapped from
realm_access.roles
andresource-access.spring-addons-confidential.roles
claims, which is where Keycloak puts users' roles. - A controller with a GET endpoint returning a greeting only if a user is granted with
NICE
authority (and 401 if authentication is missing/invalid or a 403 ifNICE
role is missing) - A controller with a GET endpoint returning a payload describing the current user: username, roles, and access expiration. For anonymous users, this should be empty strings or arrays.
- "Stateless" session management: no servlet session; client state is managed with URIs and access token
- Disabled CSRF protection (because CSRF attacks are based on server sessions, which we disable)
- Endpoints accessible to anonymous for:
- Reflecting some data from the access token (if any)
readiness
andliveness
probes
- All other routes are restricted to authenticated users (fine-grained security rules annotated on
@Controllers
methods with@PreAuthorize
). - 401 unauthorized (instead of 302 redirect to login) when the request is issued to the protected resource with a missing or invalid authorization header
- Force HTTPS usage if SSL is enabled
spring-boot-starter-oauth2-resource-server
Open Spring initializr and generate a project with the following dependencies:
- Spring Web
- OAuth2 Resource Server
- Spring Boot Actuator
- Lombok
Once downloaded and unpacked, add dependencies on spring-addons-starter-oidc which bring some additional auto-configuration to spring-boot-starter-oauth2-resource-server
or spring-boot-starter-oauth2-client
. It greatly simplifies Java security configuration. However, it is important to understand what it configures for us and what we'd have to write without it (for instance, if we had to use a different version of Spring Security than the one it is designed for). For this reason, we'll see later in this article how to write the Java configuration without spring-addons.
<dependency>
<groupId>com.c4-soft.springaddons</groupId>
<artifactId>spring-addons-starter-oidc</artifactId>
<version>7.1.14</version>
</dependency>
<dependency>
<groupId>com.c4-soft.springaddons</groupId>
<artifactId>spring-addons-starter-oidc-test</artifactId>
<version>7.1.14</version>
<scope>test</scope>
</dependency>
Now, we can configure web security:
@Configuration
@EnableMethodSecurity
public class SecurityConf {
}
Of course, we need a few entries in application.yaml
:
server:
port: 7084
com:
c4-soft:
springaddons:
oidc:
ops:
- iss: https://localhost:8443/auth/realms/master
authorities:
- path: $.realm_access.roles
- path: $.ressource_access.spring-addons-confidential.roles
username-claim: preferred_username
resourceserver:
permit-all:
- /users/me
- /actuator/health/readiness
- /actuator/health/liveness
Lastly, we need a secured REST @Controller(s)
with two endpoints:
/users/me
returning some info about the current user, which can be anonymous, as we configured this endpoint aspermitAll
in properties/greeting
returning a greeting only to users granted withNICE
authority
@RestController
@RequiredArgsConstructor
public class GreetingController {
private final GreetingService greetingService;
@GetMapping(path = "/greeting", produces = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("hasAuthority('NICE')")
public GreetingDto getGreeting() {
return new GreetingDto(greetingService.getGreeting());
}
@GetMapping(path = "/users/me", produces = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("permitAll()")
public UserDto getMe(Authentication auth) {
if(auth instanceof JwtAuthenticationToken jwt) {
final var username = jwt.getName();
final var roles = jwt.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList();
final var exp = (Instant) jwt.getToken().getExpiresAt();
return new UserDto(username, roles, exp.getEpochSecond());
}
return UserDto.ANONYMOUS;
}
static record GreetingDto(@NotEmpty String message) {
}
static record UserDto(@NotNull String username, @NotNull List<String> roles, @NotNull Long exp) {
static final UserDto ANONYMOUS = new UserDto("", List.of(), Long.MAX_VALUE);
}
}
At this point, we can use Postman to get an access token from Keycloak and then send test requests to the resource server:
- Create a new collection.
- In the Authorization tab, select OAuth2 and click "Edit token configuration" to enter the following values:
- Grant type:
Authorization Code (with PKCSE)
- Callback URL:
https://localhost:8080/login/oauth2/code/keycloak
- Auth URL:
https://localhost:8443/realms/master/protocol/openid-connect/auth
- Access token URL:
https://localhost:8443/realms/master/protocol/openid-connect/token
- Client ID:
spring-addons-confidential
- Client Secret: pick the value from the
Client
->spring-addons-confidential
->Credentials
tab in Keycloak - Scope:
openid profile email offline_access
- Grant type:
- After you authenticate as
brice
, click "use token" and add a new GET request tohttps://localhost:7084/greeting
. You should get a greeting (but not if you authenticate asigor
).
Testing
We saw how to add Role Based Access Control to our Spring methods with expressions like @PreAuthorize("hasAuthority('NICE')")
, which make assertions based on the identity and roles contained in JWT access tokens (or exposed on the authorization server introspection endpoint).
Let's now see how to test those security rules.
Unit Tests
Unit tests are focused on a single component, with all dependencies being mocked. Spring @Controllers
are unit-tested with @WebMvcTest
and MockMvc
(or @WebfluxTest
and WebTestClient
).
spring-security-test
provides with MockMvc
post-processors and WebTestClient
mutators to populate test security-context with JwtAuthenticationToken
or BearerTokenAuthentication
which are default Authentication
for apps with, respectively, JWT decoder or token introspection.
However, this comes with limitations: only @Controller
security can be tested (other @Component
unit tests do not run in the context of MockMvc
or WebTestClient
request) and it puts quite some mess in the test request definition.
As an alternative, we can add a dependency on spring-addons-starter-oidc-test (or spring-addons-oauth2-test if we are not using spring-addons-starter-oidc
) which contains test annotations similar to @WithMockUser
, injecting other types of Authentication
:
@WithMockAuthentication
, which is best suited when defining authentication names and authorities is enough.@WithJwt
, which populates the test security using a JSON payload and theConverter<Jwt, AbstractAuthenticationToken>
picked from the security configuration. This gives complete control over the claims.
Provided that we define the following JSON files in test resources:
brice.json
:
{
"iss": "https://localhost:8080/auth/realms/master",
"preferred_username": "brice",
"realm_access": {
"roles": [
"NICE",
"ACTOR"
]
},
"scope": "openid email"
}
igor.json
:
{
"iss": "https://oidc.c4-soft.com/auth/realms/master",
"preferred_username": "igor",
"realm_access": {
"roles": [
"ACTOR"
]
},
"scope": "openid email"
}
We can write unit tests as follows:
@WebMvcTest(GreetingController.class)
@AutoConfigureAddonsWebmvcResourceServerSecurity
@Import(SecurityConf.class)
class GreetingControllerTest {
private static final String greeting = "Hello!";
@MockBean
GreetingService greetingService;
@Autowired
MockMvc mockMvc;
@Autowired
WithJwt.AuthenticationFactory authFactory;
@BeforeEach
public void setUp() {
when(greetingService.getGreeting()).thenReturn(greeting);
}
@Test
void givenSecurityContextIsEmpty_whenGetGreeting_thenUnauthorized() throws Exception {
mockMvc.perform(get("/greeting")).andExpect(status().isUnauthorized());
}
@Test
@WithAnonymousUser
void givenSecurityContextIsAnonymous_whenGetGreeting_thenUnauthorized() throws Exception {
mockMvc.perform(get("/greeting")).andExpect(status().isUnauthorized());
}
@Test
@WithMockAuthentication(name = "ch4mp", authorities = {"NICE", "AUTHOR"})
void givenUserHasNiceAuthority_whenGetGreeting_thenOk() throws Exception {
mockMvc.perform(get("/greeting")).andExpect(status().isOk()).andExpect(jsonPath("$.message").value(greeting));
}
@Test
@WithMockAuthentication("AUTHOR")
void givenUserDoesNotHaveNiceAuthority_whenGetGreeting_thenForbidden() throws Exception {
mockMvc.perform(get("/greeting")).andExpect(status().isForbidden());
}
@Test
@WithJwt("brice.json")
void givenUserIsBrice_whenGetGreeting_thenOk() throws Exception {
mockMvc.perform(get("/greeting")).andExpect(status().isOk()).andExpect(jsonPath("$.message").value(greeting));
}
@Test
@WithJwt("igor.json")
void givenUserIsIgor_whenGetGreeting_thenForbidden() throws Exception {
mockMvc.perform(get("/greeting")).andExpect(status().isForbidden());
}
@Test
@WithAnonymousUser
void givenUserIsAnonymous_whenGetMe_thenOk() throws Exception {
mockMvc.perform(get("/users/me")).andExpect(status().isOk()).andExpect(jsonPath("$.username").isEmpty());
}
@ParameterizedTest
@MethodSource("identities")
void givenUserIsAuthenticated_whenGetMe_thenOk(@ParameterizedAuthentication Authentication auth) throws Exception {
mockMvc
.perform(get("/users/me"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.username").value(auth.getName()))
.andExpect(
jsonPath("$.roles").value(Matchers.containsInAnyOrder(auth.getAuthorities().stream().map(GrantedAuthority::getAuthority).toArray())));
}
Stream<AbstractAuthenticationToken> identities() {
return authFactory.authenticationsFrom("brice.json", "igor.json");
}
}
For more details about test annotations usage, refer to the GitHub repository.
BFF With Spring Cloud Gateway
As seen earlier, we want a Backend For Frontend to be configured as an OAuth2 client (with login) and the TokenRelay
filter to bridge between session authorization (for the front-end) and token authorization (for the resource servers).
As sessions use resources, we'll setup two different SecurityFilterChain
beans: one with client configuration with higher priority and security-matchers limiting it to endpoints actually requiring session (login, logout, and BFF routes), and another one acting as default, with resource server configuration for all other endpoints: actuator, Swagger UI, and also a REST endpoint exposing the login option offered by the BFF (only one in the case of this article).
Reopen Spring initializr and generate a project with the following dependencies:
- Gateway
- OAuth2 Client
- OAuth2 Resource Server
- Spring Boot Actuator
Then add spring-addons-starter-oidc
:
<dependency>
<groupId>com.c4-soft.springaddons</groupId>
<artifactId>spring-addons-starter-oidc</artifactId>
<version>7.1.13</version>
</dependency>
<dependency>
<groupId>com.c4-soft.springaddons</groupId>
<artifactId>spring-addons-starter-oidc-test</artifactId>
<version>7.1.13</version>
<scope>test</scope>
</dependency>
Here are the gateway routing configuration properties:
gateway-uri: ${scheme}://localhost:${server.port}
greetings-api-uri: ${scheme}://localhost:7084
ui-uri: ${scheme}://localhost:4200
spring:
cloud:
gateway:
default-filters:
- DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin
- SaveSession
routes:
# set a redirection from / to the UI
- id: home
uri: ${gateway-uri}
predicates:
- Path=/
filters:
- RedirectTo=301,${gateway-uri}/ui/
# BFF access to greetings API (with TokenRelay replacing session cookies with access tokens)
# To be used by SPAs (Angular app in our case)
- id: greetings-api-bff
uri: ${greetings-api-uri}
predicates:
- Path=/bff/v1/**
filters:
- TokenRelay=
- StripPrefix=2
- id: ui
uri: ${ui-uri}
predicates:
- Path=/ui/**
The important points are:
SaveSession
andDedupeResponseHeader
are applied to all routes.- For user experience, we set a redirection from
/
to/ui/
. - Requests to Angular assets are routed to the Angular dev server (with the
/ui
prefix, so we'll have to define somebaseHref
in Angular project) - All requests starting with
/bff/v1/
are routed to the resource server with theStripPrefix
(to remove/bff/v1
from the path) andTokenRelay
(to replace the session cookie with an authorization header containing the access token in session).
And here is the security configuration using Spring Boot official starters and spring-addons:
issuer: https://localhost:8443/auth/realms/master
client-id: spring-addons-confidential
client-secret: change-me
user-name-attribute: preferred_username
gateway-uri: https://localhost:8080
greetings-api-uri: https://localhost:7084
ui-uri: https://localhost:4200
spring:
security:
oauth2:
client:
provider:
keycloak:
issuer-uri: ${issuer}
user-name-attribute: ${user-name-attribute}
registration:
keycloak:
provider: keycloak
client-id: ${client-id}
client-secret: ${client-secret}
authorization-grant-type: authorization_code
scope:
- openid
- profile
- email
- offline_access
- roles
com:
c4-soft:
springaddons:
oidc:
ops:
- iss: ${issuer}
authorities:
- path: $.realm_access.roles
- path: $.resource_access.spring-addons-confidential.roles
username-claim: ${user-name-attribute}
client:
client-uri: ${gateway-uri}
security-matchers:
- /login/**
- /oauth2/**
- /logout
- /bff/**
permit-all:
- /login/**
- /oauth2/**
- /bff/**
csrf: cookie-accessible-from-js
login-path: /ui/
post-login-redirect-path: /ui/
post-logout-redirect-path: /ui/
resourceserver:
permit-all:
- /
- /login-options
- /ui/**
- /actuator/health/readiness
- /actuator/health/liveness
- /favicon.ico
Most properties are self-explanatory, but it's worth noting that com.c4-soft.springaddons
contain configuration for both an OAuth2 client filter-chain (with login) and a resource server one. The first is limited to the path defined in security-matchers
.
We also need to provide the BFF with a custom ServerLogoutSuccessHandler
:
@Configuration
public class SecurityConf {
@Component
static class SpaLogoutSucessHandler implements ServerLogoutSuccessHandler {
private final SpringAddonsServerLogoutSuccessHandler delegate;
public SpaLogoutSucessHandler(LogoutRequestUriBuilder logoutUriBuilder, ReactiveClientRegistrationRepository clientRegistrationRepo) {
this.delegate = new SpringAddonsServerLogoutSuccessHandler(logoutUriBuilder, clientRegistrationRepo);
}
@Override
public Mono<Void> onLogoutSuccess(WebFilterExchange exchange, Authentication authentication) {
return delegate.onLogoutSuccess(exchange, authentication).then(Mono.fromRunnable(() -> {
exchange.getExchange().getResponse().setStatusCode(HttpStatus.ACCEPTED);
}));
}
}
}
This bean overrides the response HTTP status of spring-addons default logout success handler: return a 201
instead of something in the 3xx
range. This allows Angular to listen for the response and "exit" by setting window.location.href
(and flush headers), instead of letting the browser follow the 3xx
redirection and throw cryptic CORS errors.
As the URI(s) to initiate the authorization_code
flow on the BFF depends on the client we use, we'll expose an endpoint listing the options for the front-end to log in users:
@RestController
@Observed(name = "GatewayController")
public class GatewayController {
private final SpringAddonsOidcClientProperties addonsClientProperties;
private final List<LoginOptionDto> loginOptions;
public GatewayController(
OAuth2ClientProperties clientProps,
SpringAddonsOidcProperties addonsProperties) {
this.addonsClientProperties = addonsProperties.getClient();
this.loginOptions = clientProps.getRegistration().entrySet().stream().filter(e -> "authorization_code".equals(e.getValue().getAuthorizationGrantType()))
.map(
e -> new LoginOptionDto(
e.getValue().getProvider(),
"%s/oauth2/authorization/%s".formatted(addonsClientProperties.getClientUri(), e.getKey())))
.toList();
}
@GetMapping(path = "/login-options", produces = "application/json")
public Mono<List<LoginOptionDto>> getLoginOptions(Authentication auth) throws URISyntaxException {
final boolean isAuthenticated = auth instanceof OAuth2AuthenticationToken;
return Mono.just(isAuthenticated ? List.of() : this.loginOptions);
}
static record LoginOptionDto(@NotEmpty String label, @NotEmpty String loginUri) {
}
}
As we configured only one "registration" with authorization_code
in-application properties, we'll have only one entry when calling /login-options
(but we could define more registrations and implement a component on the client for the user to choose one).
Securing the Frontend
As already discussed, the frontend application should be authorized with sessions and the BFF exposes URIs at which the authorization_code
flow can be initiated.
So, to log in a user, all the frontend needs is to fetch the login options, ask the user to choose one, and redirect him to a URI provided by the BFF. In our case, we'll have only one option returned by the BFF, so we don't need to ask the user to choose and can go directly to the redirection part:
export class AppComponent implements OnInit {
private loginOptions: LoginOptionDto[] = [];
constructor(
private user: UserService
) {}
ngOnInit(): void {
this.user.loginOptions().then((opts) => {
this.loginOptions = opts;
});
}
login() {
if (this.loginOptions.length !== 1) {
console.error('Invalid login options count: ', this.loginOptions);
}
this.user.login(this.loginOptions[0].loginUri);
}
logout() {
this.user.logout();
}
}
@Injectable({
providedIn: 'root',
})
export class UserService {
...
login(loginUri: string) {
window.location.href = loginUri;
}
async logout() {
return lastValueFrom(
this.http.post(`/logout`, null, { observe: 'response' })
).then((response) => {
// If logout was successful on the BFF, it should have answered with a
// Location containing an URI to end the user session on the authorization server too
const location = response.headers.get('Location');
if (!!location) {
window.location.href = location;
}
});
}
async loginOptions(): Promise<Array<LoginOptionDto>> {
return lastValueFrom(this.http.get(`${gatewayUri}/login-options`)).then(
(dto) => dto as LoginOptionDto[]
);
}
}
This is enough to authorize the session that the browser has on the BFF: Spring will fetch tokens, add them to the session, and the TokenRelay
filter on the gateway will replace the session cookie with the access token in the session before forwarding a request to our resource server.
The current user data is available from the /users/me
endpoint we defined in our resource server. Has we made this endpoint accessible to anonymous (returning empty data), all we have to do to get the user login status and basic account info from the frontend is calling that endpoint.
refresh(): void {
this.refreshSub?.unsubscribe();
this.http.get(`${usersApiUri}/me`).subscribe({
next: (dto: any) => {
const user = dto as UserDto;
this.user$.next(
user.username
? new User(user.username, user.roles || [])
: User.ANONYMOUS
);
if (!!user.username) {
const now = Date.now();
const delay = (1000 * user.exp - now) * 0.8;
if (delay > 2000) {
this.refreshSub = interval(delay).subscribe(() => this.refresh());
}
}
},
error: (error) => {
console.warn(error);
this.user$.next(User.ANONYMOUS);
},
});
}
This refresh function includes a scheduled call to itself at 80% of access expiration. This keeps the current user session alive.
Spring Security Configuration Without Spring Add-ons
In this section, we'll see what it takes to write Spring Security configuration without spring-addons-starter-oidc
. This is important for at least two reasons: to understand what happens behind the curtains (what this starter configures for us) and to avoid lock-in - have an alternative if we ever are constrained to remove it from our dependencies (for instance if we have to work with a Spring Security version for which it wasn't designed).
Of course, we are in a rather simple case with a single authorization server, no CORS configuration, and authorities mapped from a single hard-coded private claim. We'd have quite more code to write if any of this hypothesis was false.
Resource Server
We wrote a servlet resource server, which requires a SecurityFilterChain
matching the list of features we defined above:
@Configuration
@EnableMethodSecurity
@EnableWebSecurity
public class SecurityConf {
@Bean
SecurityFilterChain filterChain(HttpSecurity http, ServerProperties serverProperties, @Value("${permit-all:[]}") String[] permitAll) throws Exception {
// Configure a resource server with JWT decoder (the customized jwtAuthenticationConverter is picked by Spring Boot)
http.oauth2ResourceServer(oauth2 -> oauth2.jwt(withDefaults()));
// State-less session (state in access-token only)
http.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
// Disable CSRF because of state-less session-management
http.csrf(csrf -> csrf.disable());
// Return 401 (unauthorized) instead of 302 (redirect to login) when
// authorization is missing or invalid
http.exceptionHandling(eh -> eh.authenticationEntryPoint((request, response, authException) -> {
response.addHeader(HttpHeaders.WWW_AUTHENTICATE, "Bearer realm=\"Restricted Content\"");
response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
}));
// If SSL enabled, disable http (https only)
if (serverProperties.getSsl() != null && serverProperties.getSsl().isEnabled()) {
http.requiresChannel(channel -> channel.anyRequest().requiresSecure());
}
// @formatter:off
http.authorizeHttpRequests(requests -> requests
.requestMatchers(Stream.of(permitAll).map(AntPathRequestMatcher::new).toArray(AntPathRequestMatcher[]::new)).permitAll()
.anyRequest().authenticated());
// @formatter:on
return http.build();
}
/**
* An authorities converter using solely realm_access.roles claim as source and doing no transformation (no prefix, case untouched)
*/
@Component
static class KeycloakRealmRolesGrantedAuthoritiesConverter implements Converter<Jwt, Collection<GrantedAuthority>> {
@Override
@SuppressWarnings({ "unchecked" })
public List<GrantedAuthority> convert(Jwt jwt) {
final var realmAccess = (Map<String, Object>) jwt.getClaims().getOrDefault("realm_access", Map.of());
final var realmRoles = (List<String>) realmAccess.getOrDefault("roles", List.of());
return realmRoles.stream().map(SimpleGrantedAuthority::new).map(GrantedAuthority.class::cast).toList();
}
}
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter(Converter<Jwt, Collection<GrantedAuthority>> authoritiesConverter) {
final var jwtAuthenticationConverter = new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(authoritiesConverter);
jwtAuthenticationConverter.setPrincipalClaimName(StandardClaimNames.PREFERRED_USERNAME);
return jwtAuthenticationConverter;
}
}
This is quite more verbose and less flexible than what we did with spring-addons:
- Permit-all resources are hard coded.
- Claim to use as a source for authorities is hardcoded (and we skipped the extraction from
resource_access.spring-addons-confidential.roles
). - This accepts only one authorization server. If we wanted to accept tokens from more than just one issuer, we'd have to provide more configuration.
The above would expect the following properties:
server:
port: 7084
permit-all: >
/users/me,
/actuator/health/readiness,
/actuator/health/liveness
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://localhost:8443/auth/realms/master
Gateway as OAuth2 Client With Login and Resource Server
The first thing to care about is that spring-cloud-gateway
is a reactive application. As a consequence, we have to provide with SecurityWebFilterChain
, instead of SecurityFilterChain
like for servlets.
Also, we want some endpoints to be authorized with a session (everything that is related to login, logout, and BFF) and some other endpoints to be authorized without a session (actuator, Swagger, and /login-options). To achieve that, we'll use two different filter-chains.
To have more than one filter-chain in an application, all should have different @Order
and all but the last one in @Order
should have a securityMatcher
to define to which requests it applies to (the first match determines which filter chain is used for a request).
Security matchers can be done on about anything about a request. Here, we use request path as criteria, but it could be the origin, content of a header, body, etc.
For source code, please refer to the application.yaml and SecurityConf.java in the companion project.
To Go Further
The first resource to consider is the Spring Security manual. It is excellent and should always be your first reflex when facing a difficulty.
To learn how to override the default @ConditionalOnMissingBean
from spring-addons, you might refer to the README.
If you're interested in token introspection, you can refer to this other tutorial, "How to configure a Spring REST API with token introspection."
Last, if facing difficulties with tests, all samples and tutorials contain unit and integration tests. You might find useful tips there.
Opinions expressed by DZone contributors are their own.
Comments