Spring OAuth Client With A Test-Driven Approach
Posted in OAuth2.0 Series on June 26, 2023 by Oliver Abdulrahim ‐ 9 min read
Background
When I first implemented an OAuth2.0 client application at our organization, configuring our reactive Spring application correctly and securely took lots of time to figure out! This is the guide I wish existed doing that work.
In a previous article, I discussed OAuth in terms of the framework itself. In this post, I’ll take a test-driven approach to implementing a reactive Spring OAuth2 client in code. If you’re using classic Spring Web, much of the configuration is the same; exact class names and testing differ slightly between the two frameworks.
If you prefer to jump straight in, I’ve included two GitHub repositories to help you get started: one written in Kotlin, and the other in Java. Both use Maven for dependency management.
- kotlin-oauth2-client-starter | Dependency Graph | MIT License
- java-oauth2-client-starter | Dependency Graph | MIT License
OAuth2.0 login
I’ll start with implementing an OAuth client with basic login, which will
allow you to request OpenID Connect scopes like openid
, profile
,
email
as well as OAuth2 scopes specific to your provider.
I’ll assume you have the below dependencies added to your project and build system. If you’re using start.spring.io or IntelliJ’s Spring project creator, include these:
- Spring Reactive Web
- Spring Security
- Spring Security OAuth 2 Client
Set up your provider
Acquire a client_id
and client_secret
I’ve discussed this in a previous article, but you’ll want to register your application with an OAuth2.0 / OIDC 1.0 provider like Google, GitHub, Okta, or Microsoft.
Exact steps may differ. Ultimately, acquire a client_id
and client_secret
from your provider, and set your redirect_uri
as
<your base url>/login/oauth2/code/google
.
Add your OAuth provider details to application.yml
The simplest way to set up your provider is by adding configuration to
./src/resources/application.yml
:
spring:
security:
oauth2:
client:
registration:
google:
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
client-id: ${CLIENT_ID}
client-secret: ${CLIENT_SECRET}
authorization-grant-type: authorization_code
scope:
- openid
- profile
- email
In this example, I’ve used Google as the provider. You could also define more than one provider at once! Check the Spring documentation for more details.
I’ve defined some base OpenID Connect scopes, but you can add more OAuth scopes as needed.
This configuration also assumes that you’re supplying your client_id
and client_secret
via environment variables as is best practice.
Basic Authentication tests
Let’s define what behavior we expect from our server with some tests. First, let’s define an authentication test container class.
@SpringBootTest
@AutoConfigureWebTestClient
internal class AuthenticationTests(
@Autowired private val client: WebTestClient,
)
Here, I’ve called for Spring to inject and configure a reactive web test
client, but if you’re working with classic Spring Web, you can use
MockMvc
,
then optionally annotate your test classes with
AutoConfigureMockMvc
.
It’s fair to expect that our server to redirect unauthorized attempts to protected endpoints to our provider’s OAuth login page. Let’s add simple tests to do that.
@Test
internal fun `should not allow unauthenticated access to protected endpoint`() {
client
.get()
.uri("/user/me")
.exchange()
.expectStatus().is3xxRedirection
}
@Test
internal fun `should redirect unauthenticated access to Google login URI`() {
client
.get()
.uri("/user/me")
.exchange()
.expectHeader().valueEquals("Location", "/oauth2/authorization/google");
}
These tests actually pass without us having to do anything else!
You might have expected the first test to fail with a 404 Not Found
as
we haven’t yet defined a /user/me
endpoint — I did! However, we have
spring-boot-starter-oauth2-client
in the classpath, and we have
configured our client settings.
This gives Spring Security everything it needs to set up our OIDC
login with the secure default of 302
redirecting all unauthenticated
requests. Doing this for every such request, whether or not that endpoint
exists gives would-be attackers no additional information about what
endpoints your server has or does not have.
Test-driven development is great for isolating the moving parts in situations like this. Spring Security does a ton of autoconfiguration. Taking this approach can help us better understand what’s going on behind the scenes.
Next, let’s actually implement that /user/me
endpoint.
Add /user/me
tests and endpoint
To prove that our OAuth configuration actually works, let’s add an endpoint that returns the currently authenticated user’s name.
How can we test for that? Well, first, we need to mock up an authenticated user. We’ll use utilities provided by Spring Security Test.
internal const val MOCK_USER_FULL_NAME = "Perry The Platypus"
internal fun mockOidcUser() =
SecurityMockServerConfigurers
.mockOidcLogin()
.idToken { it.claim("name", MOCK_USER_FULL_NAME) }
Great, now we can mock up authenticated requests in our tests! Since
we expect to reuse that mock user’s name again in our tests,
I’ve defined it as a const
.
Let’s think up a contract for our /user/me
endpoint. I want it
to return an object containing the user’s full name. Let’s define
a data class to encapsulate that.
data class Name(val data: String)
Next, let’s write up a test to define exactly what we expect from
GET /user/me
.
@Test
internal fun `should return authenticated user's full name`() {
val expected = MOCK_USER_FULL_NAME
val actual = client
.mutateWith(mockOidcUser())
.get()
.uri("/user/me")
.exchange()
.expectBody<Name>()
.returnResult()
.responseBody
?.data
assertEquals(expected, actual)
}
Let’s reuse that mock user to also expect authenticated users can access that protected endpoint.
@Test
internal fun `should allow authenticated access to protected endpoint`() {
client
.mutateWith(mockOidcUser())
.get()
.uri("/user/me")
.exchange()
.expectStatus().isOk
}
Currently, our two new tests fail, as we haven’t defined that endpoint yet!
Expand to view test results
ERROR --- [main] o.s.t.w.reactive.server.ExchangeResult: Request details for assertion failure:
> GET /user/me
< 404 NOT_FOUND Not Found
java.lang.AssertionError: Status expected:<200 OK> but was:<404 NOT_FOUND>
Expected :200 OK
Actual :404 NOT_FOUND
Implement user controller
Great Let’s make a simple REST controller class.
@RestController
@RequestMapping("/user")
class UserController
Then add the /user/me
endpoint…
@GetMapping("/me")
fun getCurrentUsersName(@AuthenticationPrincipal user: OidcUser) =
Mono.just(
ResponseEntity.ok(
Name(user.idToken.claims["name"].toString())
)
)
We have a few options to retrieve the currently authenticated user.
Here, I’ve opted for the simplest, the
AuthenticationPrincipal
annotation.
All our tests now pass — excellent!
Check out that server endpoint in the browser at
http://localhost:8080/user/me
, and the generic
login page at http://localhost:8080/login
.
Session cookies set by Spring
After you log in, look closely at the cookies your app stores in the browser.
Name | Value | Domain | Path | Expires | HttpOnly |
---|---|---|---|---|---|
SESSION | a5fcd8ef… | localhost | / | Session | ✓ |
Spring Security sets a session token that allows you to stay authenticated. This cookie is only accessible by network requests and is much more secure than sending actual access tokens to the browser environment. I discussed this in more detail in a previous article.
Next steps for production
Excellent — we’ve successfully implemented a very simple OAuth client application.
Follow these next steps to get your application more ready for a production setting!
Define a SecurityWebFilterChain
bean
To further customize your server’s security rules, define a
SecurityWebFilterChain
bean. This is the Spring Security component that processes every incoming
request made to your server against rules that you set up. It’s a critical
part of user authentication and
authorization
, later CORS and CSRF
configuration we’ll add, and defines how you want your server to handle
security-related success and failure.
You can place this bean in any class annotated with
Configuration
. Convention dictates you use a class named SecurityConfiguration
, or
SecurityConfig
in a package called config
, but this is not a requirement
for it to work!
@EnableWebFluxSecurity
,
which tells Spring to use WebFlux for Spring Security!Below is the equivalent configuration for what Spring Security had set up automatically.
@Bean
internal fun securityWebFilterChain(http: ServerHttpSecurity) =
http
.authorizeExchange { it.anyExchange().authenticated() }
.oauth2Login { }
.build()
Note the empty block passed into the oauth2Login
call. You can
add additional configuration here later.
Our tests still pass after these changes, so we haven’t broken anything that we know about ;)
Configure an endpoint allowlist
In production, you’ll likely have endpoints you want to specifically
allow. Let’s update our SecurityWebFilterChain
definition to accomplish
this.
@Bean
internal fun securityWebFilterChain(http: ServerHttpSecurity) =
http
.authorizeExchange {
it.pathMatchers("/", "/login", "/error").permitAll()
.anyExchange().authenticated()
}
// other configuration omitted
Customizing OAuth2.0 success and failure behavior
After your server completes the flow, you may want to send the user-agent back to another URI. For example, back to your front end application or where the request originated.
You can do this by adding a success handler, which intercepts all successful OAuth login attempts. Here’s an example.
@Component
class OAuth2LoginSuccessHandler : ServerAuthenticationSuccessHandler {
override fun onAuthenticationSuccess(
webFilterExchange: WebFilterExchange,
authentication: Authentication
): Mono<Void> {
redirectUser(webFilterExchange.exchange.response)
// here's where you could store the user in a repository
return Mono.empty()
}
private fun redirectUser(response: ServerHttpResponse) {
response.statusCode = HttpStatus.TEMPORARY_REDIRECT
response.headers.location = URI.create("your-url")
}
}
It may help to pass in your URLs as environment variables to increase parity between your development and production environments.
Add that handler to your configuration as shown below.
@Autowired
private val successHandler: OAuth2LoginSuccessHandler
@Bean
internal fun securityWebFilterChain(http: ServerHttpSecurity) =
http
.oauth2Login {
it.authenticationSuccessHandler(successHandler)
}
// other configuration omitted
You’ll also want to add some configuration on authentication failure, perhaps sending the user-agent back to a helpful error page.
CORS configuration
If your front end application needs to log in or log out users
(you’d do this via a POST
request to your Spring server), you’ll
need to configure your back end to allow requests from that origin.
Spring Security automatically configures some sensible defaults for your application, which disallows cross-origin mutating requests. I’ll further discuss how you can implement these changes in a future article.
CSRF configuration
If your back end allows users to mutate the application state, you’ll want to protect authenticated users from cross-site request forgery (CSRF) attacks. Here, an attacker uses the user’s authentication to execute unwanted requests on their behalf.
To prevent this, set a cookie with a unique CSRF token. Set that cookie
as HttpOnly
to make it inaccessible from JavaScript. Then, require
that token on all PUT
and POST
requests.
Spring has some great built-in functions to help you do this. Again, I’ll discuss this further in a future article.
Implement a logout system
You’ll want to implement a proper logout system that invalidates the currently authenticated user’s session.
Here’s a rather simple example.
@RestController
@RequestMapping("/oauth2/logout")
class LogoutController {
@PostMapping
fun logout(session: WebSession) = session.invalidate()
}
Implement PKCE flow to prepare for OAuth2.1
Currently, our application uses the classic authorization code flow. This is already very secure; with the advent of OAuth2.1 in the near future, all applications using this flow will need to implement the PKCE step as well.
In a future article, I’ll discuss how you can do this with Spring Security.
Conclusion
I hope you found this test-driven overview of implementing a Spring OAuth client application helpful! Take your time with this topic. You want to get production application security right!
In a future article, I’ll further discuss configuring CORS and CSRF, again taking a test-driven approach.
Thanks for reading!
— Oliver Abdulrahim