Spring Boot OAuth2 workflow behind Zuul for internal client authorization
This article describes how to create Spring Boot application with oauth2 authorization using password
grant type. This grant type is appropriate for internal clients which we trust to get username and password from the user, like for example internal web UI or native mobile app.
Spring Security OAuth2: glitchy, obscure (dozen of different points to touch to configure everything together), badly documented (no single comment on configuration classes, very poor docs) and unalterable ( private
, final
, they even don't use spring dependency injection concepts and prefer instantiation of key classes using their constructors in private methods). This is the second worst project from Spring I've seen after Spring Webflow.
Furthermore there’s just lack of good examples. There’s a huge development in Spring Boot recently (2.0.0 released) and the most of examples I’ve found about Spring Boot + OAuth2 are already outdated and don’t work. Moreover people focus on things apparently different from those I need. I’ve found tons of examples how to authenticate to your app with google or facebook. Another huge package of examples are authorization server implementations going through the access code flow with approval screen. Apparently almost nobody wants to create application with standard web UI authenticated to oauth2 server with standard username and password with only optional access code flow for external clients.
Honorable mentions
Before begin I’d like to mention the only two resources worthwhile reading I’ve found. One is OAuth 2 Developers Guide which explains not much but you can treat this as a short list of hook points. The second one is very good Spring Security and Angular article. However if you print this article to PDF you’ll see it has over 100 pages, while OAuth2 is a really simple flow. The capacity of this article in my opinion shows how difficult is to explain Spring OAuth2 implementation, while oauth2 itself is meant to be simple as possible.
The goal
In our journey we will try to go step by step through configuring Spring OAuth2 for the following case: we want to have an application with (possibly) multiple internal clients for which it’s safe for user to enter his password, what should be accomplished by password
oauth2 grant type. These clients are our applications, like standard web UI or mobile clients. For these client, especialy the web UI, we want the application to behave like standard session-based application which users are used to. This means in particular automatic logout after 30 min.
Building authorization server
Let’s start with the book of building authorization server from scratch:
@SpringBootApplication
@EnableAuthorizationServer
public class OAuthASApplication implements AuthorizationServerConfigurer {
@Bean
public UserDetailsService userDetailsService() {
return new InMemoryUserDetailsManager(
User.withDefaultPasswordEncoder()
.username("user")
.password("user")
.authorities("ROLE_USER")
.build()
);
}
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("internal")
.secret("internal_secret")
.scopes("account", "contacts", "internal")
.resourceIds()
.authorizedGrantTypes("password", "refresh_token")
.autoApprove(true)
.accessTokenValiditySeconds(10*60)
.refreshTokenValiditySeconds(30*60);
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints
.userDetailsService(userDetailsService());
}
public static void main(String[] args) {
SpringApplication.run(OAuthASApplication.class, args);
}
}
What we do here is basically:
- In
userDetailsService()
we create example user details service providing users and their passwords. This bean usually uses some database, but in this example we use in-memory implementation. We create here a single user withROLE_USER
authority. - in
configure(ClientDetailsServiceConfigurer)
we create oauth2 internal clients:
- With the name:
internal
. - With the
internal_secret
password required to access/oauth/token
endpoint (with username:internal
taken from client name). - This client allows to access some hypothetical oauth2 scope indicated by
scopes()
method. - This client supports two grant types in
authorizedGrantTypes()
:password
- returns the access token after explicit username and password is given;refresh_token
- allows to get new access token using refresh token. - This client won’t display approval screen to get access to selected oauth2 scopes, but
autoApprove
-s all his scopes. - The access token will expire after 10 mins (
accessTokenValiditySeconds
) while the refresh token will expire after 30 mins (refreshTokenValiditySeconds
). TherefreshTokenValiditySeconds
reflects standard session cookie expiration time for session-based web apps.
- In
configure(AuthorizationServerEndpointsConfigurer)
we just configure previously createdUserDetailsService
to be used with authorization endpoint.
Let’s now try how does it work:
curl -X POST \
http://localhost:8080/as/oauth/token \
-H 'Authorization: Basic aW50ZXJuYWw6aW50ZXJuYWxfc2VjcmV0' \
-H 'content-type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW' \
-F grant_type=password \
-F username=user \
-F password=user
Where:
Authorization
is a Basic Auth header withinternal:internal_secret
credentials.- We want to use
grant_type=password
, i.e. get the access token after direct passing of username and password to the token endpoint.
What we get here is:
{
"error": "unsupported_grant_type",
"error_description": "Unsupported grant type: password"
}
After reading some docs it turns out configure(AuthorizationServerEndpointsConfigurer endpoints)
metod needs authentication manager setup to support password
grant type. However, AuthenticationManager
is no longer accessible as a bean in Spring Security. This is really obscure as I discussed here. Anyway, to get it working we need to create yet another security configuration providing this bean:
@Configuration
public static class AuthenticationMananagerProvider extends WebSecurityConfigurerAdapter {
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
Now, having this bean we can add it to AuthorizationServerEndpointsConfigurer
:
@Autowired protected AuthenticationManager authenticationManager;
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints
.authenticationManager(authenticationManager)
.userDetailsService(userDetailsService());
}
And check if it works again with tha same CURL, and we get … the login page.
This is the point at which I got stuck for a longer time, but finally decided to do one trick, which is using @EnableResourceServer
on authorization server configuration and creating simple HttpSecurity
config. So, it looks the authorization server cannot work properly without resource server...
@SpringBootApplication
@EnableAuthorizationServer
@EnableResourceServer
public class OAuthASApplication implements AuthorizationServerConfigurer, ResourceServerConfigurer {
@Override
public void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.anyRequest()
.authenticated(); }
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
}
}
There are two things making it really difficult to pass through. First one is you already have a place with HttpSecurity
configuration in previously created:
@Configuration
public static class AuthenticationMananagerProvider extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
// [...]
}
}
However you can spend hundreds of hours on providing various configuration here, beat your head against the wall etc, and nothing works. The Spring team decided to provide a trap here, only for persevere people.
The second thing making it really difficult to jump over is that it still doesn’t work after this fix, because our previous CURL shows the following:
{
"error": "unauthorized",
"error_description": "Full authentication is required to access this resource"
}
After another iteration of digging in the code, this time with trace
debug level it turned out that Spring OAuth2 expects getting a password in encoded form of DelegatingPasswordEncoder
, which consists of the encoder string and then the password encoded (for example: {bcrypt}$2a$10$Y[...]
). This is weird because I'd need to encode this password in javascript on client side. Fortunately we can change it to default plain password (which is safe to be sent using SSL) with yet another security config (this is another AuthorizationServerConfigurer
interface method):
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security.passwordEncoder(NoOpPasswordEncoder.getInstance());
}
With this fix this is amazing because you can get access token from the Spring OAuth2 at the very first time:
{
"access_token": "46a67de0-bf45-440e-a3d5-58aa0ee8f96e",
"token_type": "bearer",
"refresh_token": "04e1f577-8ab2-4504-887b-90c14b1ab5b7",
"expires_in": 597,
"scope": "account contacts internal"
}
At this exact moment you’re so happy you’ve got it, so “why haven’t I implemented whole this stuff myself in 2 hours or so” thought doesn’t even come to you mind.
Refreshing the token
When you implement the client for oauth2 authorization server you use the access token in Authorization: Bearer [token]
header to authorize your requests. But you also always need to consider the access token expiration time. If it expires (in this examples after 10 mins) you need to get the new access token using refresh token:
curl -X POST \
http://localhost:8080/as/oauth/token \
-H 'Authorization: Basic aW50ZXJuYWw6aW50ZXJuYWxfc2VjcmV0' \
-H 'content-type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW' \
-F grant_type=refresh_token \
-F refresh_token=04e1f577-8ab2-4504-887b-90c14b1ab5b7
What you get is another access token you can use:
{
"access_token": "0203934c-4a91-422e-ac36-45123d9f347a",
"token_type": "bearer",
"refresh_token": "04e1f577-8ab2-4504-887b-90c14b1ab5b7",
"expires_in": 599,
"scope": "account contacts internal"
}
The problem here is that refresh token ( 04e1f577-8ab2-4504-887b-90c14b1ab5b7
in the example) is still the same. This is default behavior for external application access scenarios, where refresh token has a very long lifetime, for example 14 days, and once every 14 days you need to authorize the external application again. However, if you want to authorize native web client and simulate standard session cookie behavior, which is getting logged out 30 mins after last click, the constant refresh token will be a problem. Consider the user who logs in and then is using the app more than 30 mins: he will be unconditionally logged out after some time (30 min in the example) even if he is actively clicking in the app, because this is the lifetime of refresh token.
Better approach here is to use non-reusable refresh token, what can be configured in AuthorizationServerEndpointsConfigurer
using reuseRefreshTokens()
method:
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints
.authenticationManager(authenticationManager)
.userDetailsService(userDetailsService())
.reuseRefreshTokens(false); // HERE
}
Now, after obtaining a new access token with the refresh token, the returning refresh token will be different and its timeout starts again. This way we can simulate standard web application behavior, and user will be logged out some time after his last click.
Of course there’s no simple solutions in Spring OAuth2 because in configure(ClientDetailsServiceConfigurer)
we can for example configure another client (or even multiple clients dynamically loaded from db) which is appropriate to handle external client application accomplishing access_token
grant type, and for which we may want to apply long-lived and non-reusable refresh token. Spring OAuth2 won't allow that because it encapsulated refresh token policy in one configure(AuthorizationServerEndpointsConfigurer)
method applied for all oauth2 clients.
Have a lot of fun time hacking this :)
Getting the data from the local resource server
Having such a great thing like forced resource server in my authorization server I wouldn’t be myself to not to check how does it work to get the secured resource (this is the local invocation of the service which also implements the authorization server). I’m checking this with this simple controller:
@RestController
@RequestMapping("/")
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "hello";
}
}
And Authorization: Bearer
header using previously obtained access token:
curl -X GET \
http://localhost:8080/as/hello \
-H 'Authorization: Bearer 0203934c-4a91-422e-ac36-45123d9f347a'
Getting the expected reply:
hello
Getting the data from the remote resource server
In my GitHub repository there’s a simple microservice config comprising of Zuul Proxy at http://localhost:8080
which proxies two independent microservices:
- OAuth2 Authorization Server (and resource server) at
http://localhost:8080/as
. - OAuth2 Resource Server at
http://localhost:8080/rs
.
The second resource server doesn’t know anything about authorization server, so hitting it with the same /hello
endpoint as above:
curl -X GET \
http://localhost:8080/rs/hello \
-H 'Authorization: Bearer 0203934c-4a91-422e-ac36-45123d9f347a'
Gives the following results:
{
"error": "invalid_token",
"error_description": "Invalid access token: 0203934c-4a91-422e-ac36-45123d9f347a"
}
The remote solution is still to be done here, this is why I mark this subject with [todo]. For the purpose of this article I wanted to figure out something different than my solution in the current project (see below). The first step is the standard implementation doing calls from a remote service to the authorization server confirming access tokens. The second one is to use JWT-based implementation.
NOTE: In my current system implementation I synchronize all service tokens with shared token store using Redis.
Full sources from this post can be found on GitHub.