Introduction
For this post, the objective is to dive deeper into OAuth2 authentication, especially openid connect protocol and how tokens are created and exchanged between client and authorization server. The main example used in this post is from authorization server and client in spring security’s sample repository.
Authorization Server Autoconfiguration
First of all, we examine the autoconfiguration of spring-boot-starter-oauth2-authorization-server
library. As observed, there are two, OAuth2AuthorizationServerAutoConfiguration and OAuth2AuthorizationServerJwtAutoConfiguration, autoconfigurations. OAuth2AuthorizationServerJwtAutoConfiguration initializes a JwtDecoder bean and a jwk set, which includes a new RSA key pair, bean required for that JwtDecoder.
OAuth2AuthorizationServerAutoConfiguration
On the contrary, OAuth2AuthorizationServerAutoConfiguration imports configurations from:
- OAuth2AuthorizationServerConfiguration.class
- OAuth2AuthorizationServerWebSecurityConfiguration.class
- (Ignore) OAuth2ResourceServerAutoConfiguration.class
- (Ignore) SecurityAutoConfiguration.class
- (Ignore) UserDetailsServiceAutoConfiguration.class
For this post, I want to only focus on OAuth2AuthorizationServerConfiguration.class and OAuth2AuthorizationServerWebSecurityConfiguration.class.
OAuth2AuthorizationServerConfiguration
1 |
|
The purpose of OAuth2AuthorizationServerConfiguration is to load properties values of OAuth2AuthorizationServerProperties under “spring.security.oauth2.authorizationserver”.
registeredClientRepository() turns OAuth2 provider properties into RegisteredClients and manage them in InMemoryRegisteredClientRepository. Just like InMemoryUserDetailManager does in UserDetailsServiceAutoConfiguration.class.
authorizationServerSettings() creates a shared object that exposes other setting, like the URLs for requests/revoke tokens, what signing/encryption algorithm is used etc.
OAuth2AuthorizationServerWebSecurityConfiguration
1 |
|
To go over the code step by step. The code above configs two SecurityFilterChain beans, authorizationServerSecurityFilterChain() and standardSecurityFilterChain(). The interesting part is in authorizationServerSecurityFilterChain()
OAuth2AuthorizationServerConfigurer
For OAuth2AuthorizationServerConfigurer.authorizationServerConfigurer(http)
1 | public static void applyDefaultSecurity(HttpSecurity http) throws Exception { |
The code above configures a OAuth2AuthorizationServerConfigurer, which is an aggregation of different/individual OAuth2 configurers:
1 | // L311-L318 |
Each configurer add a Filter that handles specific http requests
- OAuth2ClientAuthenticationConfigurer adds OAuth2ClientAuthenticationFilter for
- “/oauth2/token”, “/oauth2/revoke”, “/oauth2/introspect”, and “/oauth2/device_authorization”.
- OAuth2AuthorizationServerMetadataEndpointConfigurer adds OAuth2AuthorizationServerMetadataEndpointFilter for
- “/.well-known/oauth-authorization-server”.
- OAuth2AuthorizationEndpointConfigurer adds OAuth2AuthorizationEndpointFilter for
- “/oauth2/authorize”.
- OAuth2TokenEndpointConfigurer adds OAuth2TokenEndpointFilter for
- “/oauth2/token”.
- OAuth2TokenIntrospectionEndpointConfigurer adds OAuth2TokenIntrospectionEndpointFilter for
- “/oauth2/introspect”.
- OAuth2TokenRevocationEndpointConfigurer adds OAuth2TokenRevocationEndpointFilter for
- “/oauth2/revoke”.
- OAuth2DeviceAuthorizationEndpointConfigurer adds OAuth2DeviceAuthorizationEndpointFilter for
- “/oauth2/device_authorization”.
- OAuth2DeviceVerificationEndpointConfigurer adds OAuth2DeviceVerificationEndpointFilter for
- “/oauth2/device_verification”.
As observed, endpointsMatcher
is a OrRequestMatcher class that aggregates all RequestMatchers from these configurers.
OidcConfigurer
For http.getConfigurer(OAuth2AuthorizationServerConfigurer.class).oidc(Customizer.withDefaults());
This line is necessary because OAuth2AuthorizationServerConfigurer does not have Oidc support configured by default. And this basically add more Oidc’s internal configurers/filters to HttpSecurity.
- OidcProviderConfigurationEndpointConfigurer adds OidcProviderConfigurationEndpointFilter for
- “/.well-known/openid-configuration”.
- OidcLogoutEndpointConfigurer adds OidcLogoutEndpointFilter for
- “/connect/logout”.
- OidcUserInfoEndpointConfigurer adds OidcUserInfoEndpointFilter for
- “/userinfo”.
At some point, I want to go over the details of these filters.
A minimal example of authorization server
To implement a OAuth2 authorization server, we only need to add one RegistrationClient in InMemoryRegisteredClientRepository. Below shows two ways to do it:
using a Bean.
1 |
|
and using application.properties
1 | spring.security.oauth2.authorizationserver.client.login-client.registration.client-name=login-client |
Both methods are valid and should be able to pass the tests in OAuth2AuthorizationServerApplicationITests.java
Also, one thing to mention that, if the issuer is not set, the default value is “http://localhost”.
Client Autoconfiguration
Now is for the OAuth2 client. The client’s autoconfiguration is in org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration
imports OAuth2ClientRegistrationRepositoryConfiguration and OAuth2WebSecurityConfiguration
OAuth2ClientRegistrationRepositoryConfiguration
Just like OAuth2AuthorizationServerConfiguration, all OAuth2ClientRegistrationRepositoryConfiguration does is to load ClientRegistration from properties and create a InMemoryClientRegistrationRepository beam.
(When I was working on the “Debug server request workflow” part, I found out that at the client startup, it sends a “/.well-known/openid-configuration” request to the authorization server. Tracking it down, it turns out for each configured ClientRegistration, the client will try to fetch its information like URLs for “userinfo”, “auth”, “revoke” etc and also things like signing/encryption algorithms.)
OAuth2WebSecurityConfiguration
For OAuth2WebSecurityConfiguration, the code does two things. First, it creates a Repository and Service for storing/managing the authorized users, just like UserDetailsService and InMemoryUserDetailsManager. Second, it adds a default SecurityFilterChain that configuresOAuth2LoginConfigurer and OAuth2ClientConfigurer
1 |
|
By skimming through the configure() method in both class
- OAuth2ClientConfigurer configures
- OAuth2AuthorizationRequestRedirectFilter handles “/oauth2/authorization/{registrationId}”
- OAuth2AuthorizationCodeGrantFilter
- OAuth2LoginConfigurer configures
- OAuth2AuthorizationRequestRedirectFilter
- OAuth2LoginAuthenticationFilter handles “/login/oauth2/code/*”
- A LoginUrlAuthenticationEntryPoint
LoginUrlAuthenticationEntryPoint
I feel that this is worth for a small individual section. LoginUrlAuthenticationEntryPoint is configured in OAuth2LoginConfigurer. Here are the following methods:
- getLoginLinks() filters ClientRegistration that has only “authorization_code” for AuthorizationGrantType. Then, it builds authorization request URI based on ClientRegistration’s client id, and save them into a Map. For example, “login-client” becomes a Map.Entry<String, String>(“/oauth2/authorization/login-client”, “login-client”).
- getLoginEntryPoint() use the authorization request URI to create a LoginUrlAuthenticationEntryPoint.
- registerAuthenticationEntryPoint() adds the AuthenticationEntryPoint to ExceptionTranslationFilter.
By the way,
- LoginUrlAuthenticationEntryPoint is wrapped in DelegatingAuthenticationEntryPoint, for the case where there are multiple RequestMatcher matching the same entry point.
- The RequestMatch for LoginUrlAuthenticationEntryPoint is all requests except “/favicon.ico” path, “/login” path, and Ajax requests (with X-Requested-With header).
An example of OAuth2 client
To set up OAuth2 client, simply configure the following properties:
1 | spring.security.oauth2.client.registration.login-client.provider=login-client |
The comment is the authorization server setting. As observed, we replace the “authorizationserver” with “client” and the position is switched between “registration” and “login-client”. Also, in client side, Registration only allows single authorization-grant-type and client-authentication-method.
Debug client request workflow
For this part, I want to do a step by step debug to understand the communication between client and authorization server.
Initial request
To start with what we have, run both the client and the authorization server. Client uses port 8080 and the server uses port 9000. Since most requests are initiated in client side, we set a breakpoint in FilterChainProxy(L370) to see what filters are installed. Then, access the http://localhost:8080,.
We can see that there are 18 filters installed by default. Here, we are interested in the two OAuth2AuthorizationRequestRedirectFilter, the OAuth2LoginAuthenticationFilter, the OAuth2AuthorizationCodeGrantFilter, and the AuthorizationFilter.
For the current request, host is “localhost:8080” and path is “/“. So OAuth2AuthorizationRequestRedirectFilter, OAuth2LoginAuthenticationFilter, and OAuth2AuthorizationCodeGrantFilter are ignored.
AuthorizationFilter
For AuthorizationFilter, we set breakpoint at L95. Since at this point, we have not been granted with any authorization, L98 will throw a new AccessDeniedException("Access Denied");
exception and stop processing further filters.
ExceptionTranslationFilter
As the method returns, the AccessDeniedException is caught inside the ExceptionTranslationFilter at L133.
In ExceptionTranslationFilter, the catch block checks if the exception belongs to AuthenticationException or AccessDeniedException, otherwise the exception will be rethrown. Here, it handles the AccessDeniedException using handleAccessDeniedException(). The following is the steps:
- A new SecurityContext is created since existing Authentication is no longer considered valid (after communicating with the server, allow/deny does not matter).
- Save the current request in the
RequestCache requestCache
, a HttpSessionRequestCache object that saves request in HttpSession. Only works if application allows Session. - Use one of AuthenticationEntryPoints, which is LoginUrlAuthenticationEntryPoint, to redirect the authentication request to the authorization server. Below is the critical path.The
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67// ExceptionTranslationFilter.java L196
private void handleAccessDeniedException(HttpServletRequest request, HttpServletResponse response,
FilterChain chain, AccessDeniedException exception) throws ServletException, IOException {
//
sendStartAuthentication(request, response, chain,
new InsufficientAuthenticationException(
this.messages.getMessage("ExceptionTranslationFilter.insufficientAuthentication",
"Full authentication is required to access this resource")));
// ...
}
// ExceptionTranslationFilter.java L212
protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
AuthenticationException reason) throws ServletException, IOException {
// SEC-112: Clear the SecurityContextHolder's Authentication, as the
// existing Authentication is no longer considered valid
SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
this.securityContextHolderStrategy.setContext(context);
this.requestCache.saveRequest(request, response);
this.authenticationEntryPoint.commence(request, response, reason);
}
// DelegatingAuthenticationEntryPoint.java L83
// Right here, requestMatcher matches in the loop, and LoginUrlAuthenticationEntryPoint is used.
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
for (RequestMatcher requestMatcher : this.entryPoints.keySet()) {
logger.debug(LogMessage.format("Trying to match using %s", requestMatcher));
if (requestMatcher.matches(request)) {
AuthenticationEntryPoint entryPoint = this.entryPoints.get(requestMatcher);
logger.debug(LogMessage.format("Match found! Executing %s", entryPoint));
entryPoint.commence(request, response, authException);
return;
}
}
logger.debug(LogMessage.format("No match found. Using default entry point %s", this.defaultEntryPoint));
// No EntryPoint matched, use defaultEntryPoint
this.defaultEntryPoint.commence(request, response, authException);
}
// LoginUrlAuthenticationEntryPoint.java L124
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
if (!this.useForward) {
// redirect to login page. Use https if forceHttps true
String redirectUrl = buildRedirectUrlToLoginPage(request, response, authException);
this.redirectStrategy.sendRedirect(request, response, redirectUrl);
return;
}
// ...
}
// DefaultRedirectStrategy.java L64
public void sendRedirect(HttpServletRequest request, HttpServletResponse response, String url) throws IOException {
String redirectUrl = calculateRedirectUrl(request.getContextPath(), url);
redirectUrl = response.encodeRedirectURL(redirectUrl);
// ...
if (this.statusCode == HttpStatus.FOUND) {
response.sendRedirect(redirectUrl);
}
else {
// ...
}
}String redirectUrl = buildRedirectUrlToLoginPage(request, response, authException);
builds a redirect URL using the server’s host and port from the request, which is localhost and 8080, and concatenates them with “/oauth2/authorization/login-client” to create “http://localhost:8080/oauth2/authorization/login-client“. Then,response.sendRedirect(redirectUrl);
will send the request using tomcat’s jakarta servlet api.
Process redirect request
Right now, the server localhost:8080 receives a request for resource “/oauth2/authorization/login-client”. Here, OAuth2AuthorizationRequestRedirectFilter is used because the path matches “/oauth2/authorization/{registrationId}”.
OAuth2AuthorizationRequestResolver
By default, OAuth2AuthorizationRequestRedirectFilter uses DefaultOAuth2AuthorizationRequestResolver to resolve the client id, which is “login-client”, looks up the ClientRegistration from the InMemoryClientRegistrationRepository, and builds a OAuth2AuthorizationRequest.
1 |
|
One thing to mention, both nonce and state are generated here using Based64 encoding and added to the builder.
Next, just like before, this.sendRedirectForAuthorization(request, response, authorizationRequest);
will save the request first in the session and then go through DefaultRedirectStrategy L64 to send the request.
At this point, our “localhost:8080” request is redirected to “http://localhost:9000/login“. This is the result.
Process Oidc Authorization
After inputting “user” and “password”, the default credential set in the serve end, the response is sent from the authorization server to our client side using “http://127.0.0.1:8080/login/oauth2/code/login-client“. OAuth2LoginAuthenticationFilter is used to process the request because the path matches “/login/oauth2/code/*”.
OAuth2LoginAuthenticationFilter
Like UsernamePasswordAuthenticationFilter, OAuth2LoginAuthenticationFilter extends AbstractAuthenticationProcessingFilter.class. So its attemptAuthentication()
method is used to check for authentication.
1 |
|
To dissect the code step by step.
MultiValueMap<String, String> params = OAuth2AuthorizationResponseUtils.toMultiMap(request.getParameterMap());
extracts the parameters into a Map. A valid request should contain at least two entries, “code” and “state”.isAuthorizationResponse()
utilizes helper methods in OAuth2AuthorizationResponseUtils to check the validity of the request.As you see, it checks if the parameter has “code” or “error”1
2
3
4
5
6
7
8
9
10
11
12static boolean isAuthorizationResponse(MultiValueMap<String, String> request) {
return isAuthorizationResponseSuccess(request) || isAuthorizationResponseError(request);
}
static boolean isAuthorizationResponseSuccess(MultiValueMap<String, String> request) {
return StringUtils.hasText(request.getFirst(OAuth2ParameterNames.CODE))
&& StringUtils.hasText(request.getFirst(OAuth2ParameterNames.STATE));
}
static boolean isAuthorizationResponseError(MultiValueMap<String, String> request) {
return StringUtils.hasText(request.getFirst(OAuth2ParameterNames.ERROR))
&& StringUtils.hasText(request.getFirst(OAuth2ParameterNames.STATE));
}OAuth2AuthorizationRequest authorizationRequest = this.authorizationRequestRepository.removeAuthorizationRequest(request, response);
retrieves/remove the authorization request that is stored in HttpSession and compare its “state” value with request’s “state” value.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
Assert.notNull(request, "request cannot be null");
String stateParameter = getStateParameter(request);
if (stateParameter == null) {
return null;
}
OAuth2AuthorizationRequest authorizationRequest = getAuthorizationRequest(request);
return (authorizationRequest != null && stateParameter.equals(authorizationRequest.getState()))
? authorizationRequest : null;
}
private OAuth2AuthorizationRequest getAuthorizationRequest(HttpServletRequest request) {
HttpSession session = request.getSession(false);
return (session != null) ? (OAuth2AuthorizationRequest) session.getAttribute(this.sessionAttributeName) : null;
}- Next couple lines retrieves ClientRegistration based on the client id stored in the authorization request.
- Up until
OAuth2AuthenticationToken oauth2Authentication = this.authenticationResultConverter.convert(authenticationResult);
, the code construct aOAuth2LoginAuthenticationToken authenticationRequest
that is ready to be authenticated by the authentication ProviderManager. - There are 4 authentication providers in the ProviderManager:
- AnonymousAuthenticationProvider
- OAuth2LoginAuthenticationProvider: for requests without openid scope
- OidcAuthorizationCodeAuthenticationProvider: for requests with openid scope
- OAuth2AuthorizationCodeAuthenticationProvider
Because ClientRegistration “login-client” has “openid” scope, OidcAuthorizationCodeAuthenticationProvider is chosen to authenticate the request.
OidcAuthorizationCodeAuthenticationProvider
The Authentication authenticate(Authentication authentication)
in OidcAuthorizationCodeAuthenticationProvider is the entry point for authenticating the request. The majority
1 |
|
The most important work happens at OAuth2AccessTokenResponse accessTokenResponse = getResponse(authorizationCodeAuthentication);
. It turns out another http request is sent to “http://localhost:9000/oauth2/token“ to get the “access_token” from the authorization server. Here is an example of request and response
1 | # Request |
The response contains an access_token and an id_token. id_token is just an Base64 encoded Jwt, use Jwt.io debugger to decode the example. OidcIdToken idToken = createOidcToken(clientRegistration, accessTokenResponse);
will decode the id_token into Jwt object and validateNonce(authorizationRequest, idToken);
validate the nonce value in Jwt against the hash of nonce value stored in authorization request.
After this, OidcUser oidcUser = this.userService.loadUser(new OidcUserRequest(clientRegistration, accessTokenResponse.getAccessToken(), idToken, additionalParameters));
will send another http request to “http://localhost:9000/userinfo“ to retrieve the user info. For example
1 | # Request |
The returned “sub” will be checked against id_token’s “sub” to prevent the token substitution attacks. After an OidcUser is successfully created and all scopes/authorities mapped. An OAuth2LoginAuthenticationToken with granted authentication is created and returned.
Interestingly, the token used for “http://localhost:9000/oauth2/token“ has very short expiration time.
- After obtain an OAuth2LoginAuthenticationToken, an OAuth2AuthorizedClient that contains all information such as AuthenticationToken, ClientRegistration and the server access_token, is created and saved in the repository, which is AuthenticatedPrincipalOAuth2AuthorizedClientRepository.
Finally, after all these steps, just like BearerTokenAuthenticationFilter, successfulAuthentication(request, response, chain, authenticationResult)
method is called and the authentication result is saved in the SecurityContext. However, the only difference is that, HttpSessionSecurityContextRepository is used instead of RequestAttributeSecurityContextRepository because OAuth2LoginAuthenticationFilter is stateful and BearerTokenAuthenticationFilter is stateless.
Post Authorization (Oidc) request
Any future requests made to “localhost:8080” is processed by AuthenticationFilter which will retrieve the AuthenticationToken from the session storage, verify the AuthenticationToken and grant/deny access.
Logout
Logout is simpler than I have thought. All LogoutFilter does is to invalidate/clear the session and return success (redirect to /login). And interestingly it turns out Every authorization request made it the authorization server will return unique access_token and id_token (“jti” value in jwt is different for every request). So, the authorization server is stateless.