Building SSO based on Spring Authorization Server (Part 3 of 3)

D Snezhinskiy
13 min readNov 8, 2023

--

This is the final article about building an SSO system based on Spring Authorization Server. In this article, we will explore in detail the integration of the Social Login authentication method. And finally, we will configure the Resource Server to interact with our SSO system.

Table of Contents

Part 1 — link

  • Introduction
  • Chapter 1: Code Grant Authorization
  • Chapter 2: Grant Password Authorization

Part 2 — link

  • Chapter 3: Transition to Opaque Tokens
  • Chapter 4: PostgreSQL + Role Model

Part 3

  • Chapter 5: Authorization using Social Login (Google as an example)
  • Chapter 6: Resource Server Configuration

Chapter 5: Authorization using Social Login (Google as an example)

I believe this method of authorization doesn’t require much explanation. We all use it almost daily:

Social Login is single sign-on for end users. Using existing login information from a social network provider like Facebook, Twitter, or Google, the user can sign into a third party website instead of creating a new account specifically for that website. This simplifies registrations and logins for end users.

As for the specifics of configuring Spring Authorization Server, I’ll leave this link here for your reference so you can have the documentation on hand during setup — How-to: Authenticate using Social Login

Please pay attention to the useful links at the very beginning:

If you’ve never registered your application before, this will be particularly helpful. I will describe the process using Google as an example.

If you are already familiar with the Google App Console, you can skip this part. Next, I will explain and demonstrate everything in detail, targeting an audience that has never done this before.

So, first, we need to go to the Google App Console and register our application.

The terminology might be confusing, so the following steps and their sequence are marked on the screenshot.

Go to the Credentials section and click on the “configure consent screen” button.

We land on the “OAuth consent screen” page, where you can choose the User Type — internal or external (I chose external). Then, you will be asked to fill in information about your application.

After completing that, return to the Credentials page.

While on the “Create OAuth ID” page, it’s essential to ensure the correct information is entered in both the “Name” and “Authorized Redirect URL” fields.

The “Name” corresponds to the clientName, and the “Authorized redirect URL” should be the address of the page to which the user will be redirected after successfully authorizing with Google. In our case, it should be — (http://localhost:8081/login/oauth2/code/google).

Let’s first understand what a redirect URL is and where it comes from. The documentation states the following:

The default Redirect URI template is {baseUrl}/login/oauth2/code/{registrationId}. See Setting the Redirect URI in the Spring Security reference for more information.
For example, testing locally on port 9000 with a registrationId of google, your Redirect URI would be http://localhost:9000/login/oauth2/code/google. Enter this value as the Redirect URI when setting up the application with your provider.

We won’t make any changes to the redirect URL scheme, we’ll just replace the port with 8081, and that’s it.

After submitting the form, you will receive a set of ClientId and ClientSecret, as well as a JSON file with additional settings that we will need a little later.

Now we need to pass the Client ID and Client Secret to our application. However, it’s not secure to store such sensitive data directly in a configuration file. Instead, we will pass them as VM options.

Now that we have set up our testing environment, let’s move on to configuring our application. At first, we need to add the dependency spring-boot-starter-oauth2-client to have the ability to interact with the remote authorization server.

//...

implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

//...

Next, following the documentation, we add an oauth2Login() block to the defaultSecurityFilterChain.

SecurityConfiguration.java

//...

@Bean
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(
authorize -> authorize
.anyRequest().authenticated()
)
.formLogin(withDefaults())
.oauth2Login(withDefaults())
.logout((logout) -> logout.permitAll())
.build();
}

//...

Enabling OpenID Connect (OIDC) support in AuthorizationServerConfiguration.

OpenID Connect (OIDC) is an identity layer built on top of the OAuth 2.0 framework. It allows third-party applications to verify the identity of the end-user and to obtain basic user profile information. OIDC uses JSON web tokens (JWTs), which you can obtain using flows conforming to the OAuth 2.0 specifications. See our OIDC Handbook for more details.

AuthorizationServerConfiguration.java

//...

http
.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.tokenEndpoint(tokenEndpoint ->
tokenEndpoint
.accessTokenRequestConverter(new OAuth2GrantPasswordAuthenticationConverter())
.authenticationProvider(grantPasswordAuthenticationProvider)
.authenticationProvider(daoAuthenticationProvider)
)
.oidc(Customizer.withDefaults()); // Enable OpenID Connect 1.0

//...

All other configurations are simply added to the application.yml file, where GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET are filled in from environment variables. As for authorization-uri, token-uri, and user-info-uri, they are obtained from the JSON file generated by Google for us.

//...

spring:
security:
oauth2:
client:
registration:
google:
provider: google
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
provider:
google:
authorization-uri: https://accounts.google.com/o/oauth2/auth
token-uri: https://oauth2.googleapis.com/token
user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo
//...

We have completed configuring the minimal working configuration with Google authorization support. Let’s test it. Go directly to http://localhost:8081/foo to reach the controller’s page after authorization.

After authorizing on Google, we return and see the following in the logs:

Now, let’s take a look at what’s inside the authentication object in the FooController.

We can see that the Principal already has a different type — DefaultOidcUser. It seems to me that it’s not very convenient to have Principal of type UserDetails in all other cases, but not in case of OIDC with DefaultOidcUser.

The first place we will face problems in the accessTokenCustomizer because we are expecting UserDetails there. Creating our own CustomOidcUser, which will also implement UserDetails, would be more convenient.

Another motivation for this is the need to keep authorities mutable because in the future, I want to have the ability to enrich them with default values for newly registered users. And, we’ll also add a few additional fields that might come in handy.

CustomOidcUser.java

@Getter
@Setter
public class CustomOidcUser extends DefaultOidcUser implements UserDetails {
private UUID id;
private String username;
private boolean active;
private LocalDateTime createdAt;
private Collection<? extends GrantedAuthority> authorities = new HashSet<>();

public CustomOidcUser(Collection<? extends GrantedAuthority> authorities, OidcIdToken idToken) {
super(authorities, idToken, null, IdTokenClaimNames.SUB);
}

public CustomOidcUser(Collection<? extends GrantedAuthority> authorities, OidcIdToken idToken, String nameAttributeKey) {
super(authorities, idToken, null, nameAttributeKey);
}

public CustomOidcUser(Collection<? extends GrantedAuthority> authorities, OidcIdToken idToken, OidcUserInfo userInfo) {
this(authorities, idToken, userInfo, IdTokenClaimNames.SUB);
}

public CustomOidcUser(Collection<? extends GrantedAuthority> authorities, OidcIdToken idToken, OidcUserInfo userInfo, String nameAttributeKey) {
super(AuthorityUtils.NO_AUTHORITIES, idToken, userInfo, nameAttributeKey);
/**
* Keep the authorities mutable
*/
if (authorities != null) {
this.authorities = authorities;
}

this.createdAt = LocalDateTime.now();
}

public CustomOidcUser(OidcIdToken idToken, OidcUserInfo userInfo) {
super(AuthorityUtils.NO_AUTHORITIES, idToken, userInfo);
}

@Override
public String getPassword() {
return null;
}

@Override
public boolean isAccountNonExpired() {
return true;
}

@Override
public boolean isAccountNonLocked() {
return true;
}

@Override
public boolean isCredentialsNonExpired() {
return false;
}

@Override
public boolean isEnabled() {
return true;
}

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.authorities;
}

public User toInstantUser() {
return User.builder()
.username(getUsername())
.firstName(getGivenName())
.middleName(getMiddleName())
.lastName(getFamilyName())
.avatarUrl(getPicture())
.locale(getLocale())
.active(isActive())
.createdAt(getCreatedAt())
.build();
}
}

We already have the CustomUserDetailsService, and similarly, we’ll define the CustomOidcUserService, which will retrieve users from the database based on a unique field, in our case, the email, and return an OidcUser. We won’t create a separate @Bean. Instead, we’ll declare it as an @Service and inherit it from OidcUserService. We only need to override one method, OidcUser loadUser(OidcUserRequest userRequest).

CustomOidcUserService.java

@Service
@RequiredArgsConstructor
public class CustomOidcUserService extends OidcUserService {
private final UserService userService;
private final Map<String, OidcUserMapper> mappers;

@Override
public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException {
OidcUser oidcUser = super.loadUser(userRequest);
String registrationId = userRequest.getClientRegistration().getRegistrationId();
Assert.isTrue(mappers.containsKey(registrationId), "No mapper defined for such registrationId");
OidcUserMapper mapper = mappers.get(userRequest.getClientRegistration().getRegistrationId());
String email = userRequest.getIdToken().getEmail();
User localUser = userService.getByUsername(email);

if (localUser != null) {
return mapper.map(oidcUser.getIdToken(), oidcUser.getUserInfo(), localUser);
}

//Map unregistered user
return mapper.map(oidcUser);
}
}

If the user is found, we fill in all the fields of CustomOidcUser from the User entity. If not, we only populate it with the properties obtained in OidcUser.

In general, attribute mapping in this case will depend on the social login provider, so OidcUserMapper is just an interface, and in our example, it has only one implementation, GoogleOidcUserMapper. For convenience, we annotate it with @Component("google").

GoogleOidcUserMapper.java

@Component("google")
public class GoogleOidcUserMapper implements OidcUserMapper {

public OidcUser map(OidcUser oidcUser) {
CustomOidcUser user = new CustomOidcUser(oidcUser.getIdToken(), oidcUser.getUserInfo());
user.setUsername(oidcUser.getEmail());
//Enable new users by default
user.setActive(true);
return user;
}

public OidcUser map(OidcIdToken idToken, OidcUserInfo userInfo, User user) {
Set<GrantedAuthority> authorities = user.getRoles().stream()
.flatMap(role -> role.getAuthorities().stream()
.map(authority -> new SimpleGrantedAuthority(authority.getName()))
)
.collect(Collectors.toSet());

Map<String,Object> claims = new HashMap<>();
claims.putAll(idToken.getClaims());
claims.put(StandardClaimNames.GIVEN_NAME, user.getFirstName());
claims.put(StandardClaimNames.MIDDLE_NAME, user.getMiddleName());
claims.put(StandardClaimNames.FAMILY_NAME, user.getLastName());
claims.put(StandardClaimNames.LOCALE, user.getLocale());
claims.put(StandardClaimNames.PICTURE, user.getAvatarUrl());

OidcIdToken customIdToken = new OidcIdToken(
idToken.getTokenValue(), idToken.getIssuedAt(), idToken.getExpiresAt(), claims
);

CustomOidcUser oidcUser = new CustomOidcUser(authorities, customIdToken, userInfo);
oidcUser.setId(user.getId());
oidcUser.setUsername(user.getUsername());
oidcUser.setCreatedAt(user.getCreatedAt());
oidcUser.setActive(user.isActive());
return oidcUser;
}
}

As you can probably guess, if you need to add a new identity provider, such as GitHub, you will need to define your mapper with the annotation @Component(“github”).

So, we’ve implemented data retrieval from the database for users who already exist in the system. However, we would also like to automatically save a user to the database after their first login if they are not found. Thanks to the framework developers, this case is separately described in the documentation as — Capture User in Database.

All we need to do is copy the code of SocialLoginAuthenticationSuccessHandler without any modifications.

SocialLoginAuthenticationSuccessHandler.java

public class SocialLoginAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private final AuthenticationSuccessHandler delegate = new SavedRequestAwareAuthenticationSuccessHandler();

private Consumer<OAuth2User> oauth2UserHandler = (user) -> {};

private Consumer<OidcUser> oidcUserHandler = (user) -> this.oauth2UserHandler.accept(user);

@Override
public void onAuthenticationSuccess(
HttpServletRequest request, HttpServletResponse response, Authentication authentication
) throws IOException, ServletException {
if (authentication instanceof OAuth2AuthenticationToken) {
if (authentication.getPrincipal() instanceof OidcUser) {
this.oidcUserHandler.accept((OidcUser) authentication.getPrincipal());
} else if (authentication.getPrincipal() instanceof OAuth2User) {
this.oauth2UserHandler.accept((OAuth2User) authentication.getPrincipal());
}
}

this.delegate.onAuthenticationSuccess(request, response, authentication);
}

public void setOAuth2UserHandler(Consumer<OAuth2User> oauth2UserHandler) {
this.oauth2UserHandler = oauth2UserHandler;
}

public void setOidcUserHandler(Consumer<OidcUser> oidcUserHandler) {
this.oidcUserHandler = oidcUserHandler;
}
}

Following the documentation, we also add our UserServiceOAuth2UserHandler, where we create a User object for all new users. Next we copy all the fields into it and assign the default role (USER). And finally, we save the user, making sure to update the authorities of the OidcUser object. This step is essential to be able to verify permissions when accessing the introspection endpoint in the future.

UserServiceOAuth2UserHandler.java

@Component
@RequiredArgsConstructor
public class UserServiceOAuth2UserHandler implements Consumer<OidcUser> {
private final UserService userService;
private final RoleService roleService;

@Override
public void accept(OidcUser user) {
// Capture user in a local data store on first authentication
CustomOidcUser oidcUser = (CustomOidcUser)user;

if (oidcUser.getId() == null
&& this.userService.getByUsername(user.getName()) == null
) {
Collection<GrantedAuthority> grantedAuthorities = (Collection<GrantedAuthority>)oidcUser.getAuthorities();
User localUser = oidcUser.toInstantUser();
Role defaultRole = roleService.getDefaultRole();

if (defaultRole != null) {
localUser.setRoles(Set.of(defaultRole));
}

this.userService.save(localUser);

if (!CollectionUtils.isEmpty(localUser.getRoles())) {
Set<? extends GrantedAuthority> authorities = localUser.getRoles().stream()
.flatMap(role -> role.getAuthorities().stream()
.map(authority -> new SimpleGrantedAuthority(authority.getName()))
)
.collect(Collectors.toSet());

grantedAuthorities.addAll(authorities);
}

oidcUser.setId(localUser.getId());
}
}
}

To ensure that SocialLoginAuthenticationSuccessHandler is invoked after a successful authentication, we need to declare it as a bean and then register it in the defaultSecurityFilterChain within the oauth2Login(…) section.

SecurityConfiguration.java

//...

@Bean
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http,
AuthenticationSuccessHandler authenticationSuccessHandler
) throws Exception {
return http
.authorizeHttpRequests(
authorize -> authorize
.anyRequest().authenticated()
)
.formLogin(withDefaults())
.oauth2Login(oauth ->
oauth
.successHandler(authenticationSuccessHandler)
)
.logout((logout) -> logout.permitAll())
.build();
}

@Bean
public AuthenticationSuccessHandler authenticationSuccessHandler(UserServiceOAuth2UserHandler handler) {
SocialLoginAuthenticationSuccessHandler authenticationSuccessHandler =
new SocialLoginAuthenticationSuccessHandler();
authenticationSuccessHandler.setOidcUserHandler(handler);
return authenticationSuccessHandler;
}

//...

If you now authenticate and go to the FooController, here’s what you’ll see.

Now, the server setup can be considered complete. Of course, this is a minimal configuration, and for full functionality, you should at least replace the InMemory storage with something else.

Then, if you’re as paranoid as I am, I think it would be worth changing the addresses of public endpoints 😎. However, for testing purposes, what has been done so far is sufficient.

Chapter 6: Resource Server Configuration

At the final step we will configure the Resource Server to interact with the SSO.

We need to make sure that after receiving a request from the client, the Resource Server communicates with our SSO for user authentication using an Opaque token and retrieves a list of authorities. Based on these authorities, it makes a decision on whether to grant the user access to the resource.

Let’s begin by creating a Gradle project named resourceServer. Similar to the authorizationServer, I'm adding it as a module in the parent project demosso. You can make it a separate project if you prefer.

Add the following dependencies:

dependencies {
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
}

Next, we need to create SecurityConfiguration and configure it according to the recommendations from the “Using introspectionUri()” section. But I would like to make some changes to how access privileges are configured. Here’s how the example from the documentation recommends setting up privilege checking:

//...

http
.authorizeHttpRequests(authorizeRequests -> authorizeRequests
.requestMatchers("/contacts/**").hasAuthority("SCOPE_contacts")
.requestMatchers("/messages/**").hasAuthority("SCOPE_messages")
.anyRequest().authenticated()
)

//...

I find this approach not very convenient. It’s much more flexible to have the ability to set permissions for each method individually using the @PreAuthorize annotation. To enable this feature, we also need to add the @EnableMethodSecurity(prePostEnabled = true) annotation to SecurityConfiguration.

SecurityConfiguration.java

@Configuration
@EnableMethodSecurity(prePostEnabled = true)
@EnableWebSecurity
public class SecurityConfiguration {

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.oauth2ResourceServer(
auth -> auth
.opaqueToken(
opaqueTokenConfigurer -> opaqueTokenConfigurer
.introspectionUri("http://localhost:8081/oauth2/introspect")
.introspectionClientCredentials("demo-client", "demo-secret")
)
);

http.authorizeHttpRequests(
authorizationManagerRequestMatcherRegistry ->
authorizationManagerRequestMatcherRegistry.anyRequest()
.authenticated()
);

return http.build();
}
}

In introspectionUri(), specify the introspection endpoint, and in introspectionClientCredentials(), pass the clientId and clientSecret.

And for testing purposes, we will create a DemoArticleController with three endpoints:

  1. /demo/public - accessible to any authenticated user.
  2. /demo/read - accessible only to users with the ARTICLE_READ authority.
  3. /demo/write - accessible only to users with a higher-level authority than ARTICLE_WRITE.

For more convenience during debugging, each method contains the Authentication object retrieved from the SecurityContext.

DemoArticleController.java

@RestController
public class DemoArticleController {
@GetMapping("/demo/public")
public String demoPublic() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return "demo public string";
}

@PreAuthorize("hasAuthority('ARTICLE_READ')")
@GetMapping("/demo/read")
public String read() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return "demo read string";
}

@PreAuthorize("hasAuthority('ARTICLE_WRITE')")
@GetMapping("/demo/write")
public String write() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return "demo write string";
}
}

Now we are ready for the first test run. Start both projects, authorizationServer and resourceServer, and authorize using any of the three methods on authorizationServer. Our goal is to obtain an opaque token. For the first test, I authenticated as an admin.

Now, all that’s left is to navigate to the http://localhost:8080/demo/public endpoint and check what we’ve achieved. To do this, weneed to add the Authorization header with the parameter Authorization = Bearer <Opaque AccessToken>.

Here’s what the Authentication object looks like in the controller.

It’s visible that the attributes contain the desired username and authorities, but they weren’t copied to the parent object’s authorities property. This happens because, as mentioned earlier, these are non-standard attributes, and the OpaqueTokenAuthenticationProvider, which processes the introspection response and constructs BearerTokenAuthentication, doesn’t know what to do with these attributes.

As you can see, the attributes contain the desired username and authorities, but they haven’t been copied to the parent object’s authorities property. This happens because, as mentioned earlier, these are non-standard (i.e. not registered) claims, and the OpaqueTokenAuthenticationProvider, which processes the introspection response and constructs BearerTokenAuthentication, doesn’t know what to do with these attributes.

To fix this, we simply need to define a custom version of CustomOpaqueTokenAuthenticationConverter that constructs BearerTokenAuthentication correctly by populating all the necessary properties. Additionally, we will override OAuth2AuthenticatedPrincipal to include authorities and username to it.

CustomOAuth2AuthenticatedPrincipal.java

public class CustomOAuth2AuthenticatedPrincipal implements OAuth2AuthenticatedPrincipal {
private String username;
private Collection<? extends GrantedAuthority> authorities;
private Map<String, Object> attributes;

public CustomOAuth2AuthenticatedPrincipal(String username, Collection<? extends GrantedAuthority> authorities, Map<String, Object> attributes) {
this.username = username;
this.authorities = authorities;
this.attributes = attributes;
}

@Override
public String getName() {
return username;
}

@Override
public Map<String, Object> getAttributes() {
return attributes;
}

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
}

CustomOpaqueTokenAuthenticationConverter.java

public class CustomOpaqueTokenAuthenticationConverter implements OpaqueTokenAuthenticationConverter {

@Override
public Authentication convert(String introspectedToken, OAuth2AuthenticatedPrincipal authenticatedPrincipal) {
Map<String, Object> attributes = authenticatedPrincipal.getAttributes();

// authorities (OPTIONAL)
Collection<? extends GrantedAuthority> authorities = AuthorityUtils.NO_AUTHORITIES;
if (authenticatedPrincipal.getAttributes().containsKey("authorities")) {
authorities =
((List<String>)authenticatedPrincipal.getAttributes().get("authorities")).stream()
.map(auth -> new SimpleGrantedAuthority(auth))
.collect(Collectors.toUnmodifiableSet());
}

// username (OPTIONAL)
String username = null;
if (attributes.containsKey("username")
&& StringUtils.hasText((String) attributes.get("username"))
) {
username = (String) attributes.get("username");
}

OAuth2AccessToken accessToken = new OAuth2AccessToken(
OAuth2AccessToken.TokenType.BEARER,
introspectedToken,
authenticatedPrincipal.getAttribute(IdTokenClaimNames.IAT),
authenticatedPrincipal.getAttribute(IdTokenClaimNames.EXP)
);

CustomOAuth2AuthenticatedPrincipal customOAuth2User = new CustomOAuth2AuthenticatedPrincipal(username, authorities, attributes);

return new BearerTokenAuthentication(
customOAuth2User,
accessToken,
customOAuth2User.getAuthorities()
);
}
}

Now, as usual, in order to make all this work, we need to create a bean for OpaqueTokenAuthenticationConverter in the configuration and add it to the securityFilterChain.

SecurityConfiguration.java

//...

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http,
OpaqueTokenAuthenticationConverter opaqueTokenAuthenticationConverter
) throws Exception {
http.oauth2ResourceServer(
auth -> auth
.opaqueToken(
opaqueTokenConfigurer -> opaqueTokenConfigurer
.introspectionUri("http://localhost:8081/oauth2/introspect")
.introspectionClientCredentials("demo-client", "demo-secret")
.authenticationConverter(opaqueTokenAuthenticationConverter)
)
);

http.authorizeHttpRequests(
authorizationManagerRequestMatcherRegistry ->
authorizationManagerRequestMatcherRegistry.anyRequest()
.authenticated()
);

return http.build();
}

@Bean
public OpaqueTokenAuthenticationConverter opaqueTokenAuthenticationConverter() {
return new CustomOpaqueTokenAuthenticationConverter();
}

//...

Now everything is ready for another test. It will be more revealing if we will authorize again as user and then navigate to http://localhost:8080/demo/public once more.

Now we can see, that the username and authorities are populated and we can proceed to test the remaining two endpoints. As expected, http://localhost:8080/demo/read will return an HTTP status 200. And http://localhost:8080/demo/write return us 403 Forbidden response, which is also expected.

Summary

We’ve completed a significant amount of work. To be honest, this article turned out much longer than I expected. However, it allowed us to see what happens under the hood of the Spring Authorization Server, and I hope it highlights numerous non-obvious issues that inevitably arise for those embarking on this journey for the first time.

GitHub project link.

For your convenience, branch numbers and chapter numbers are consistent. This means that each branch serves as a snapshot of the result at the end of each chapter.

--

--