SpringBoot security fourth look: Oidc authorization server and client

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(OAuth2AuthorizationServerProperties.class)
class OAuth2AuthorizationServerConfiguration {
private final OAuth2AuthorizationServerPropertiesMapper propertiesMapper;

OAuth2AuthorizationServerConfiguration(OAuth2AuthorizationServerProperties properties) {
this.propertiesMapper = new OAuth2AuthorizationServerPropertiesMapper(properties);
}

@Bean
@ConditionalOnMissingBean
@Conditional(RegisteredClientsConfiguredCondition.class)
RegisteredClientRepository registeredClientRepository() {
return new InMemoryRegisteredClientRepository(this.propertiesMapper.asRegisteredClients());
}

@Bean
@ConditionalOnMissingBean
AuthorizationServerSettings authorizationServerSettings() {
return this.propertiesMapper.asAuthorizationServerSettings();
}

}

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

OAuth2AuthorizationServerWebSecurityConfiguration
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
@Configuration(proxyBeanMethods = false)
@ConditionalOnDefaultWebSecurity
@ConditionalOnBean({ RegisteredClientRepository.class, AuthorizationServerSettings.class })
class OAuth2AuthorizationServerWebSecurityConfiguration {
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class).oidc(withDefaults());
http.oauth2ResourceServer((resourceServer) -> resourceServer.jwt(withDefaults()));
http.exceptionHandling((exceptions) -> exceptions.defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/login"), createRequestMatcher()));
return http.build();
}

@Bean
@Order(SecurityProperties.BASIC_AUTH_ORDER)
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated()).formLogin(withDefaults());
return http.build();
}

private static RequestMatcher createRequestMatcher() {
MediaTypeRequestMatcher requestMatcher = new MediaTypeRequestMatcher(MediaType.TEXT_HTML);
requestMatcher.setIgnoredMediaTypes(Set.of(MediaType.ALL));
return requestMatcher;
}

}

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)

OAuth2AuthorizationServerConfigurer
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static void applyDefaultSecurity(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
new OAuth2AuthorizationServerConfigurer();
RequestMatcher endpointsMatcher = authorizationServerConfigurer
.getEndpointsMatcher();

http
.securityMatcher(endpointsMatcher)
.authorizeHttpRequests(authorize ->
authorize.anyRequest().authenticated()
)
.csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher))
.apply(authorizationServerConfigurer);
}

The code above configures a OAuth2AuthorizationServerConfigurer, which is an aggregation of different/individual OAuth2 configurers:

OAuth2AuthorizationServerConfigurer
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// L311-L318
List<RequestMatcher> requestMatchers = new ArrayList<>();
this.configurers.values().forEach(configurer -> {
configurer.init(httpSecurity);
requestMatchers.add(configurer.getRequestMatcher());
});
requestMatchers.add(new AntPathRequestMatcher(
authorizationServerSettings.getJwkSetEndpoint(), HttpMethod.GET.name()));
this.endpointsMatcher = new OrRequestMatcher(requestMatchers);

private Map<Class<? extends AbstractOAuth2Configurer>, AbstractOAuth2Configurer> createConfigurers() {
Map<Class<? extends AbstractOAuth2Configurer>, AbstractOAuth2Configurer> configurers = new LinkedHashMap<>();
configurers.put(OAuth2ClientAuthenticationConfigurer.class, new OAuth2ClientAuthenticationConfigurer(this::postProcess));
configurers.put(OAuth2AuthorizationServerMetadataEndpointConfigurer.class, new OAuth2AuthorizationServerMetadataEndpointConfigurer(this::postProcess));
configurers.put(OAuth2AuthorizationEndpointConfigurer.class, new OAuth2AuthorizationEndpointConfigurer(this::postProcess));
configurers.put(OAuth2TokenEndpointConfigurer.class, new OAuth2TokenEndpointConfigurer(this::postProcess));
configurers.put(OAuth2TokenIntrospectionEndpointConfigurer.class, new OAuth2TokenIntrospectionEndpointConfigurer(this::postProcess));
configurers.put(OAuth2TokenRevocationEndpointConfigurer.class, new OAuth2TokenRevocationEndpointConfigurer(this::postProcess));
configurers.put(OAuth2DeviceAuthorizationEndpointConfigurer.class, new OAuth2DeviceAuthorizationEndpointConfigurer(this::postProcess));
configurers.put(OAuth2DeviceVerificationEndpointConfigurer.class, new OAuth2DeviceVerificationEndpointConfigurer(this::postProcess));
return configurers;
}

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.

OAuth2AuthorizationServerConfigurer
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
@Bean
public registeredClientRepository() {
// @formatter:off
RegisteredClient loginClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("login-client")
.clientSecret("{noop}openid-connect")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.redirectUri("http://127.0.0.1:8080/login/oauth2/code/login-client")
.redirectUri("http://127.0.0.1:8080/authorized")
.scope(OidcScopes.OPENID)
.scope(OidcScopes.PROFILE)
.clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
.build();
RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("messaging-client")
.clientSecret("{noop}secret")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.scope("message:read")
.scope("message:write")
.build();
// @formatter:on
return new InMemoryRegisteredClientRepository(loginClient, registeredClient);
}

@Bean
public AuthorizationServerSettings providerSettings() {
return AuthorizationServerSettings.builder().issuer("http://localhost:9000").build();
}

@Bean
public UserDetailsService userDetailsService() {
// @formatter:off
UserDetails userDetails = User
.withUsername("user")
.password("{noop}password")
.build();
// @formatter:on
return new InMemoryUserDetailsManager(userDetails);
}

and using application.properties

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
spring.security.oauth2.authorizationserver.client.login-client.registration.client-name=login-client
spring.security.oauth2.authorizationserver.client.login-client.registration.client-id=login-client
spring.security.oauth2.authorizationserver.client.login-client.registration.client-secret={noop}openid-connect
spring.security.oauth2.authorizationserver.client.login-client.registration.client-authentication-methods=client_secret_basic
spring.security.oauth2.authorizationserver.client.login-client.registration.authorization-grant-types=authorization_code
spring.security.oauth2.authorizationserver.client.login-client.registration.redirect-uris=http://127.0.0.1:8080/login/oauth2/code/login-client,http://127.0.0.1:8080/authorized
spring.security.oauth2.authorizationserver.client.login-client.registration.scopes=openid,profile
spring.security.oauth2.authorizationserver.client.login-client.require-authorization-consent=true

spring.security.oauth2.authorizationserver.client.messaging-client.registration.client-name=messaging-client
spring.security.oauth2.authorizationserver.client.messaging-client.registration.client-id=messaging-client
spring.security.oauth2.authorizationserver.client.messaging-client.registration.client-secret={noop}secret
spring.security.oauth2.authorizationserver.client.messaging-client.registration.client-authentication-methods=client_secret_basic
spring.security.oauth2.authorizationserver.client.messaging-client.registration.authorization-grant-types=client_credentials
spring.security.oauth2.authorizationserver.client.messaging-client.registration.scopes=message:read,message:write

spring.security.oauth2.authorizationserver.issuer=http://localhost:9000

spring.security.user.name=user
spring.security.user.password={noop}password

server.port=9000

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
2
3
4
5
6
7
8
9
10
11
@Configuration(proxyBeanMethods = false)
@ConditionalOnDefaultWebSecurity
static class OAuth2SecurityFilterChainConfiguration {
@Bean
SecurityFilterChain oauth2SecurityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests((requests) -> requests.anyRequest().authenticated());
http.oauth2Login(withDefaults());
http.oauth2Client(withDefaults());
return http.build();
}
}

By skimming through the configure() method in both class

  • OAuth2ClientConfigurer configures
    • OAuth2AuthorizationRequestRedirectFilter handles “/oauth2/authorization/{registrationId}”
    • OAuth2AuthorizationCodeGrantFilter
  • OAuth2LoginConfigurer configures

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:

application.properties
1
2
3
4
5
6
7
8
9
10
11
12
13
spring.security.oauth2.client.registration.login-client.provider=login-client
spring.security.oauth2.client.registration.login-client.client-name=login-client
spring.security.oauth2.client.registration.login-client.client-id=login-client
spring.security.oauth2.client.registration.login-client.client-secret=openid-connect
spring.security.oauth2.client.registration.login-client.client-authentication-method=client_secret_basic
spring.security.oauth2.client.registration.login-client.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.login-client.redirect-uri=http://127.0.0.1:8080/login/oauth2/code/login-client
spring.security.oauth2.client.registration.login-client.scope=openid,profile
spring.security.oauth2.client.provider.login-client.issuer-uri=http://localhost:9000

# spring.security.oauth2.authorizationserver.client.login-client.registration.client-name=login-client
# spring.security.oauth2.authorizationserver.client.login-client.registration.authorization-grant-types=authorization_code

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,
debug_VirtualFilterChain.
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:

  1. A new SecurityContext is created since existing Authentication is no longer considered valid (after communicating with the server, allow/deny does not matter).
  2. Save the current request in the RequestCache requestCache, a HttpSessionRequestCache object that saves request in HttpSession. Only works if application allows Session.
  3. Use one of AuthenticationEntryPoints, which is LoginUrlAuthenticationEntryPoint, to redirect the authentication request to the authorization server. Below is the critical path.
    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.
    @Override
    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
    @Override
    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
    @Override
    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 {
    // ...
    }
    }
    The 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
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
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
try {
OAuth2AuthorizationRequest authorizationRequest = this.authorizationRequestResolver.resolve(request);
if (authorizationRequest != null) {
this.sendRedirectForAuthorization(request, response, authorizationRequest);
return;
}
}
...
}

@Override
public OAuth2AuthorizationRequest resolve(HttpServletRequest request) {
// looks up the client id, "login-client"
String registrationId = resolveRegistrationId(request);
if (registrationId == null) {
return null;
}
String redirectUriAction = getAction(request, "login");
return resolve(request, registrationId, redirectUriAction);
}

private OAuth2AuthorizationRequest resolve(HttpServletRequest request, String registrationId, String redirectUriAction) {
if (registrationId == null) {
return null;
}
// looks up the ClientRegistration from the InMemoryClientRegistrationRepository
ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId(registrationId);
if (clientRegistration == null) {
throw new InvalidClientRegistrationIdException("Invalid Client Registration with Id: " + registrationId);
}
OAuth2AuthorizationRequest.Builder builder = getBuilder(clientRegistration);

// redirectUriStr is "http://127.0.0.1:8080/login/oauth2/code/login-client"
String redirectUriStr = expandRedirectUri(request, clientRegistration, redirectUriAction);

// @formatter:off
builder.clientId(clientRegistration.getClientId())
.authorizationUri(clientRegistration.getProviderDetails().getAuthorizationUri())
.redirectUri(redirectUriStr)
.scopes(clientRegistration.getScopes())
.state(DEFAULT_STATE_GENERATOR.generateKey());
// @formatter:on

this.authorizationRequestCustomizer.accept(builder);

return builder.build();
}

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
debug_redirect_login.

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
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
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
MultiValueMap<String, String> params = OAuth2AuthorizationResponseUtils.toMultiMap(request.getParameterMap());
if (!OAuth2AuthorizationResponseUtils.isAuthorizationResponse(params)) {
OAuth2Error oauth2Error = new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
OAuth2AuthorizationRequest authorizationRequest = this.authorizationRequestRepository
.removeAuthorizationRequest(request, response);
if (authorizationRequest == null) {
OAuth2Error oauth2Error = new OAuth2Error(AUTHORIZATION_REQUEST_NOT_FOUND_ERROR_CODE);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
String registrationId = authorizationRequest.getAttribute(OAuth2ParameterNames.REGISTRATION_ID);
ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId(registrationId);
if (clientRegistration == null) {
OAuth2Error oauth2Error = new OAuth2Error(CLIENT_REGISTRATION_NOT_FOUND_ERROR_CODE,
"Client Registration not found with Id: " + registrationId, null);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
// @formatter:off
String redirectUri = UriComponentsBuilder.fromHttpUrl(UrlUtils.buildFullRequestUrl(request))
.replaceQuery(null)
.build()
.toUriString();
// @formatter:on
OAuth2AuthorizationResponse authorizationResponse = OAuth2AuthorizationResponseUtils.convert(params,
redirectUri);
Object authenticationDetails = this.authenticationDetailsSource.buildDetails(request);
OAuth2LoginAuthenticationToken authenticationRequest = new OAuth2LoginAuthenticationToken(clientRegistration,
new OAuth2AuthorizationExchange(authorizationRequest, authorizationResponse));
authenticationRequest.setDetails(authenticationDetails);
OAuth2LoginAuthenticationToken authenticationResult = (OAuth2LoginAuthenticationToken) this
.getAuthenticationManager()
.authenticate(authenticationRequest);
OAuth2AuthenticationToken oauth2Authentication = this.authenticationResultConverter
.convert(authenticationResult);
Assert.notNull(oauth2Authentication, "authentication result cannot be null");
oauth2Authentication.setDetails(authenticationDetails);
OAuth2AuthorizedClient authorizedClient = new OAuth2AuthorizedClient(
authenticationResult.getClientRegistration(), oauth2Authentication.getName(),
authenticationResult.getAccessToken(), authenticationResult.getRefreshToken());

this.authorizedClientRepository.saveAuthorizedClient(authorizedClient, oauth2Authentication, request, response);
return oauth2Authentication;
}

To dissect the code step by step.

  1. 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”.
  2. isAuthorizationResponse() utilizes helper methods in OAuth2AuthorizationResponseUtils to check the validity of the request.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    static 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));
    }
    As you see, it checks if the parameter has “code” or “error”
  3. 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
    @Override
    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;
    }
  4. Next couple lines retrieves ClientRegistration based on the client id stored in the authorization request.
  5. Up until OAuth2AuthenticationToken oauth2Authentication = this.authenticationResultConverter.convert(authenticationResult);, the code construct a OAuth2LoginAuthenticationToken authenticationRequest that is ready to be authenticated by the authentication ProviderManager.
  6. 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

OidcAuthorizationCodeAuthenticationProvider
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
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
OAuth2LoginAuthenticationToken authorizationCodeAuthentication = (OAuth2LoginAuthenticationToken) authentication;
// Section 3.1.2.1 Authentication Request -
// https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
// scope
// REQUIRED. OpenID Connect requests MUST contain the "openid" scope value.
if (!authorizationCodeAuthentication.getAuthorizationExchange()
.getAuthorizationRequest()
.getScopes()
.contains(OidcScopes.OPENID)) {
// This is NOT an OpenID Connect Authentication Request so return null
// and let OAuth2LoginAuthenticationProvider handle it instead
return null;
}
OAuth2AuthorizationRequest authorizationRequest = authorizationCodeAuthentication.getAuthorizationExchange()
.getAuthorizationRequest();
OAuth2AuthorizationResponse authorizationResponse = authorizationCodeAuthentication.getAuthorizationExchange()
.getAuthorizationResponse();
if (authorizationResponse.statusError()) {
throw new OAuth2AuthenticationException(authorizationResponse.getError(),
authorizationResponse.getError().toString());
}
if (!authorizationResponse.getState().equals(authorizationRequest.getState())) {
OAuth2Error oauth2Error = new OAuth2Error(INVALID_STATE_PARAMETER_ERROR_CODE);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
OAuth2AccessTokenResponse accessTokenResponse = getResponse(authorizationCodeAuthentication);
ClientRegistration clientRegistration = authorizationCodeAuthentication.getClientRegistration();
Map<String, Object> additionalParameters = accessTokenResponse.getAdditionalParameters();
if (!additionalParameters.containsKey(OidcParameterNames.ID_TOKEN)) {
OAuth2Error invalidIdTokenError = new OAuth2Error(INVALID_ID_TOKEN_ERROR_CODE,
"Missing (required) ID Token in Token Response for Client Registration: "
+ clientRegistration.getRegistrationId(),
null);
throw new OAuth2AuthenticationException(invalidIdTokenError, invalidIdTokenError.toString());
}
OidcIdToken idToken = createOidcToken(clientRegistration, accessTokenResponse);
validateNonce(authorizationRequest, idToken);
OidcUser oidcUser = this.userService.loadUser(new OidcUserRequest(clientRegistration,
accessTokenResponse.getAccessToken(), idToken, additionalParameters));
Collection<? extends GrantedAuthority> mappedAuthorities = this.authoritiesMapper
.mapAuthorities(oidcUser.getAuthorities());
OAuth2LoginAuthenticationToken authenticationResult = new OAuth2LoginAuthenticationToken(
authorizationCodeAuthentication.getClientRegistration(),
authorizationCodeAuthentication.getAuthorizationExchange(), oidcUser, mappedAuthorities,
accessTokenResponse.getAccessToken(), accessTokenResponse.getRefreshToken());
authenticationResult.setDetails(authorizationCodeAuthentication.getDetails());
return authenticationResult;
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# Request
POST http://localhost:9000/oauth2/token
Accept: application/json;charset=UTF-8
Content-Type: application/x-www-form-urlencoded;charset=UTF-8
Authorization: Basic bG9naW4tY2xpZW50Om9wZW5pZC1jb25uZWN0

grant_type=authorization_code&redirect_uri=http://127.0.0.1:8080/login/oauth2/code/login-client&code=DssbKVRw1YTXPF7Mj867CTEKppxdTxiRczmdMUH3ntCjXwiCDsY8zgM5p4eUZj2lnSRtG4AeqYjshnyOW1RXbBLVmEoDR23_iycH5qO6l2vCfezKiHBvMt45-PyyCgy5

# Response
# omit headers
{
"access_token": "eyJraWQiOiI2NjA4OTcwOS0xZDMzLTRjODctODE2ZS1hMWQyOTJlMTEzNmMiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyIiwiYXVkIjoibG9naW4tY2xpZW50IiwibmJmIjoxNzA3NzI1MDYzLCJzY29wZSI6WyJvcGVuaWQiLCJwcm9maWxlIl0sImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6OTAwMCIsImV4cCI6MTcwNzcyNTM2MywiaWF0IjoxNzA3NzI1MDYzLCJqdGkiOiJhZmRlOWNiZC0zOTVjLTQ4YTAtODZmNi0zNjk3YWJlNjQ0MDQifQ.eU0X-iHa4IpMUkE3sG0HVHM4nxs2PRkcD0wi9d_nTqaT9mbuaNdz7JsdgDRuGCRGEBv6Xj7rkF848V5cv5g4jbELiyf_cfbvf9-uqjKowaqHPeFBDiNh1RDuZmXGhd2lAZrspspW6zHH1190G_M3z8Uny1DZjAQ134a54FO10LGFBHAOv5VnFEHqbU_ljRtFHdZen-ki3YrjFlD0xJZquuLds3BuWjkEqWNCr355NArfnfU7L3QbokxYjvHyGdI7M14I0z_5maHHhFBRgLDPfh6E53_BSAkER9HksPwBMWXiJcx9Wy4A66iO2jlmi8K_Bwf3EU5O717v59V0-DJzoQ",
"scope": "openid profile",
"id_token": "eyJraWQiOiI2NjA4OTcwOS0xZDMzLTRjODctODE2ZS1hMWQyOTJlMTEzNmMiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyIiwiYXVkIjoibG9naW4tY2xpZW50IiwiYXpwIjoibG9naW4tY2xpZW50IiwiYXV0aF90aW1lIjoxNzA3NzIzMTIzLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjkwMDAiLCJleHAiOjE3MDc3MjY4NjMsImlhdCI6MTcwNzcyNTA2Mywibm9uY2UiOiJUOTNyQW0xV0V6c3ZLOF9jQ3lXRkRjSjM4Z0hZUjRVNEZRT2ZnVDhWR2ZzIiwianRpIjoiNmJiYmE5YzctMzY4NC00ZTA4LTk1YzUtY2M2NzExMzlmYmUxIiwic2lkIjoiNk5HdEotS2ctRFNZUlNWUnU1TjBoekZnTWJCNWRKdHJmYi1FaV9jenl2byJ9.BkWz0ThSmJFVt-9CP0jGt_X5fnkN_33Wt7Fb6YXCPegIism3E2dnMmlBT97mTYrIA3362TnC_xAHMkTbbxK27rTQyC8OpzFga89U9Fhd5_CEoPd7YhH4qB-KT184EhfzZq10bxoU-9QknrzPlgCygqwcMtQZwdXcnL7y_ta-30TXMF6sP3XbCModwke914B12YJq2r2aL82o03ri44v3PCeA63UrJWDy4PZDyaYVpWTkF5QcTPu3MR4FMEyl6PDmVJgBBUuN3Rw66Lv2GoPvbequNIYkXpqaaRIJND4BMTt5GxYshUkL9eZ66NrvTYtL56J0trODxDxh07qevjDJFA",
"token_type": "Bearer",
"expires_in": 299
}

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
2
3
4
5
6
7
8
9
10
# Request
GET http://localhost:9000/userinfo
Accept: application/json
Authorization: Bearer eyJraWQiOiI2NjA4OTcwOS0xZDMzLTRjODctODE2ZS1hMWQyOTJlMTEzNmMiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyIiwiYXVkIjoibG9naW4tY2xpZW50IiwibmJmIjoxNzA3NzI2NjIzLCJzY29wZSI6WyJvcGVuaWQiLCJwcm9maWxlIl0sImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6OTAwMCIsImV4cCI6MTcwNzcyNjkyMywiaWF0IjoxNzA3NzI2NjIzLCJqdGkiOiIwMjk0MmZmYi04OTkwLTQ5MWUtYmQ1Yy04NDk1YWEzZTBiNmUifQ.LN9kZZSIowkbGjbTGN7BIP9jBx6ksGWgehxn-UjzxaRh2LuZ9PvLxHzpYadJUv4S_RjjatkywRG4svtWLP3tWu_1IHoBJrvOjHhwUnakV8JU6k9u-F28iFi5WreqaBzP9wVwKTDjnn3ESslNuISPp8ob8CGOSziVXcE94zrvcE-vgagl18-ID_aheQC3olPXFe_T4-UQAV3IowQBEB3DHI0UlQDJ2lKZXAFjXr-iUe-5zeeRgg3CyGhyWsNDiez0RNbdxiKeqFfHORkO6fLRJuChNR3X5DN5AJ7EH0t2dboLaBQf1akaTvJCAjxC3kh8fB_G4tBX1NL8q7GoPa9APA

# Response
# omit headers
{
"sub": "user"
}

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.

  1. 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.

  • Home