In the previous post, we explored the concept of OAuth 2 and the considerations for its implementation. In this post, we will discuss how to extend OAuth 2 to configure a Single Sign-On (SSO) environment, and how to build an SSO environment using Spring Boot and Spring Security OAuth.

1. Extending OAuth 2 for SSO Environment

To set up SSO using OAuth, we used the authorization code grant type, which is one of the authorization methods provided by OAuth. The SSO server was configured with the two standard OAuth endpoints and two custom endpoints. We will review the login, SSO handling, and logout scenarios in the SSO environment.

1.1. Login Scenario

The login scenario in the configured SSO environment is as follows. Each system redirects the request from an unauthenticated user to the SSO server.

  1. If there is no valid authentication history, the SSO server responds with the login page.
  2. The SSO server, upon receiving a login request with valid credentials, redirects the request to the system (including the authorization code).
  3. The system, upon receiving the request with the authorization code, obtains the access token and user information from the SSO server and processes the login for the user, then responds with the result of the original request.

1.2. SSO Processing Scenario

The SSO processing scenario in an SSO-configured environment is as follows. When a user logged into one system attempts to access a page that requires authentication on another system, the system redirects the request to the SSO server.

  1. For a request with valid authentication history, the SSO server redirects the request to the system (including the authorization code).
  2. The system, upon receiving the request with the authorization code, obtains the access token and user information from the SSO server and processes the login for the user, then responds with the result of the original request.

1.3. Logout Scenario

When a logged-in user logs out of the SSO environment, the process is as follows:

  1. The logged-in user requests a logout from one system, which redirects the request to the logout page on the SSO server.
  2. Upon receiving the logout request, the SSO server sends logout requests to all systems where the user is logged in.

2. Building the SSO Environment

To set up the SSO environment described earlier, we used Spring Boot and Spring Security OAuth2. First, we will explain how to set up the SSO server, followed by how to build the client systems.

2.1. SSO Server Setup

2.1.1. Project Structure

The overall structure of the SSO server project is shown below. For simplicity, test-related configurations have been omitted.

After creating the project with Maven, we edit the pom.xml file to include Spring Boot and other required libraries.

<project ......>
  ......
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>1.5.2.RELEASE</version>
  </parent>

  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.security.oauth</groupId>
      <artifactId>spring-security-oauth2</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-jdbc</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-devtools</artifactId>
      <optional>true</optional>
    </dependency>

    <dependency>
      <groupId>com.h2database</groupId>
      <artifactId>h2</artifactId>
      <scope>runtime</scope>
    </dependency>
    <dependency>
      <groupId>org.apache.httpcomponents</groupId>
      <artifactId>httpclient</artifactId>
    </dependency>

    <dependency>
      <groupId>org.webjars</groupId>
      <artifactId>jquery</artifactId>
      <version>3.2.0</version>
    </dependency>
    <dependency>
      <groupId>org.webjars</groupId>
      <artifactId>bootstrap</artifactId>
      <version>3.3.7</version>
    </dependency>
  </dependencies>
  ......
</project>

2.1.2. Spring Boot Application Configuration File

Under the src/main/resources directory, create the application configuration file application.yml as follows:

server:
  port: 8080
  session:
    cookie:
      name: APPSESSIONID

spring.h2.console:
  enabled: true
  path: /h2-console

spring:
  jpa:
    generate-ddl: false
    hibernate:
      ddl-auto: none

logging.level:
  root: warn
  org.springframework:
    web: warn
    security: info
    boot: info
  org.hibernate:
    SQL: warn
  com.nextree: debug

This configuration sets the port to 8080 and defines the session cookie as APPSESSIONID.

The H2 database console is enabled at /h2-console, and JPA is used for database access, with DDL generation disabled.

2.1.3. Spring Boot Application Class

The main class with the @SpringBootApplication annotation starts the SSO server when run.

@SpringBootApplication
public class SsoServerApplication {
  public static void main(String[] args) {
    SpringApplication.run(SsoServerApplication.class, args);
  }
}

2.1.4. Web Security Configuration Class

The class that enables Spring Security settings and defines login, authorization, and logout behavior.

@Configuration
@EnableWebSecurity
@Order(SecurityProperties.ACCESS_OVERRIDE_ORDER)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http
      .authorizeRequests()
        .antMatchers("/home", "/webjars/**", "/css/**", "/userInfo").permitAll()
        .anyRequest().authenticated()
        .and()
      .formLogin()
        .loginProcessingUrl("/login")
        .loginPage("/loginForm")
        .permitAll()
        .and()
      .csrf()
        .requireCsrfProtectionMatcher(new AntPathRequestMatcher("/user*"))
        .disable()
      .logout()
        .permitAll();
  }

  @Autowired
  public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
    auth
      .inMemoryAuthentication()
      .withUser("tsong").password("aaa").roles("USER").and()
      .withUser("jmpark").password("aaa").roles("USER").and()
      .withUser("jkkang").password("aaa").roles("USER").and()
      .withUser("test").password("aaa").roles("USER");
  }
}

This configures Spring Security, allowing unrestricted access to certain URLs and requiring authentication for others.

2.1.5. Web MVC Configuration Class

This class configures view controllers for pages like the homepage and login form.

@Configuration
public class WebMvcConfig extends WebMvcConfigurerAdapter {
  @Override
  public void addViewControllers(ViewControllerRegistry registry) {
    registry.addViewController("/home").setViewName("home");
    registry.addViewController("/loginForm").setViewName("loginForm");
  }
}

2.1.6. Authorization Server Configuration Class

By using @EnableAuthorizationServer, we enable OAuth authorization functionality in Spring Boot.

@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter {
  @Autowired
  private AuthorizationCodeServices authorizationCodeServices;
  
  @Autowired
  private ApprovalStore approvalStore;
  
  @Autowired
  private TokenStore tokenStore;
  
  @Override
  public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
    endpoints
      .tokenStore(tokenStore)
      .authorizationCodeServices(authorizationCodeServices)
      .approvalStore(approvalStore);
  }

  @Bean
  public AuthorizationCodeServices jdbcAuthorizationCodeServices(DataSource dataSource) {
    return new JdbcAuthorizationCodeServices(dataSource);
  }

  @Bean
  public ApprovalStore jdbcApprovalStore(DataSource dataSource) {
    return new JdbcApprovalStore(dataSource);
  }

  @Bean
  @Primary
  public ClientDetailsService jdbcClientDetailsService(DataSource dataSource) {
    return new JdbcClientDetailsService(dataSource);
  }

  @Bean
  public TokenStore jdbcTokenStore(DataSource dataSource) {
    return new JdbcTokenStore(dataSource);
  }
}

To store the SSO authentication target clients, authorization codes, and issued token information in the database, I configured the JdbcAuthorizationCodeServices, JdbcApprovalStore, JdbcClientDetailsService, and JdbcTokenStore provided by Spring Security OAuth2 as beans in the authorization server configuration class.

2.1.7. Database Schema Creation and Initial Data Script

To create the database schema used by the database-related beans registered in the authorization server class, you can write a schema.sql file containing the DDL statements in the src/main/resources directory.

Additionally, if initial data needs to be loaded, you can create a data.sql file containing DML statements in the same directory. These scripts will be executed when the Spring Boot application starts, creating the schema and loading the initial data. Below are the contents of the schema.sql and data.sql files.

create table oauth_client_details (
  client_id VARCHAR(256) PRIMARY KEY,
  resource_ids VARCHAR(256),
  client_secret VARCHAR(256),
  scope VARCHAR(256),
  authorized_grant_types VARCHAR(256),
  web_server_redirect_uri VARCHAR(256),
  logout_uri VARCHAR(256),
  base_uri VARCHAR(256),
  authorities VARCHAR(256),
  access_token_validity INTEGER,
  refresh_token_validity INTEGER,
  additional_information VARCHAR(4096),
  autoapprove VARCHAR(256)
);

create table oauth_access_token (
  token_id VARCHAR(256),
  token LONGVARBINARY,
  authentication_id VARCHAR(256) PRIMARY KEY,
  user_name VARCHAR(256),
  client_id VARCHAR(256),
  authentication LONGVARBINARY,
  refresh_token VARCHAR(256)
);

create table oauth_refresh_token (
  token_id VARCHAR(256),
  token LONGVARBINARY,
  authentication LONGVARBINARY
);

create table oauth_code (
  code VARCHAR(256), authentication LONGVARBINARY
);

create table oauth_approvals (
  userId VARCHAR(256),
  clientId VARCHAR(256),
  scope VARCHAR(256),
  status VARCHAR(10),
  expiresAt TIMESTAMP,
  lastModifiedAt TIMESTAMP
);


insert into oauth_client_details (client_id, client_secret,
    resource_ids, scope, authorized_grant_types,
    web_server_redirect_uri, authorities, access_token_validity,
    refresh_token_validity, additional_information,
    autoapprove, logout_uri, base_uri)
  values ('System1_id', 'System1_secret',
    null, 'read', 'authorization_code',
    'http://localhost:18010/oauthCallback', 'ROLE_YOUR_CLIENT', 36000,
    2592000, null,
    'true', 'http://localhost:18010/logout', 'http://localhost:18010/me');

insert into oauth_client_details (client_id, client_secret,
    resource_ids, scope, authorized_grant_types,
    web_server_redirect_uri, authorities, access_token_validity,
    refresh_token_validity, additional_information,
    autoapprove, logout_uri, base_uri)
  values ('System2_id', 'System2_secret',
    null, 'read', 'authorization_code',
    'http://localhost:18020/oauthCallback', 'ROLE_YOUR_CLIENT', 36000,
    2592000, null,
    'true', 'http://localhost:18020/logout', 'http://localhost:18020/me');

insert into oauth_client_details (client_id, client_secret,
    resource_ids, scope, authorized_grant_types,
    web_server_redirect_uri, authorities, access_token_validity,
    refresh_token_validity, additional_information,
    autoapprove, logout_uri, base_uri)
  values ('System3_id', 'System3_secret',
    null, 'read', 'authorization_code',
    'http://localhost:18030/oauthCallback', 'ROLE_YOUR_CLIENT', 36000,
    2592000, null,
    'true', 'http://localhost:18030/logout', 'http://localhost:18030/me');

insert into oauth_client_details (client_id, client_secret,
    resource_ids, scope, authorized_grant_types,
    web_server_redirect_uri, authorities, access_token_validity,
    refresh_token_validity, additional_information,
    autoapprove, logout_uri, base_uri)
  values ('System4_id', 'System4_secret',
    null, 'read', 'authorization_code',
    'http://localhost:18040/oauthCallback', 'ROLE_YOUR_CLIENT', 36000,
    2592000, null,
    'true', 'http://localhost:18040/logout', 'http://localhost:18040/me');

2.1.8. Entity Classes

In the next step, we define JPA entities to store and retrieve OAuth-related data. These entities map to the database tables you created earlier.

@Entity
@Table(name="oauth_client_details")
public class Client {
  @Id
  @Column(name="client_id")
  private String clientId;

  @Column(name="web_server_redirect_uri")
  private String redirectUri;

  @Column(name="logout_uri")
  private String logoutUri;

  @Column(name="base_uri")
  private String baseUri;

  // getter/setter
}
@Entity
@Table(name="oauth_access_token")
public class AccessToken {
  @Id
  private String tokenId;

  private String token;

  @Column(name="user_name")
  private String userName;

  @Column(name="authentication_id")
  private String authenticationId;

  @Column(name="client_id")
  private String clientId;

  private String authentication;

  // getter /setter
}

2.1.9. JPA Repositories

Now, let's define the repositories using Spring Data JPA to handle the entities.

public interface ClientRepository extends CrudRepository<Client, String> {
  // Custom queries if needed
}
public interface AccessTokenRepository extends CrudRepository<AccessToken, String> {
  AccessToken findByTokenIdAndClientId(String tokenId, String clientId);

  int deleteByUserName(String userName);

  List<AccessToken> findByUserName(String userName);
}

2.1.10. Service Interface and Implementation

We define a service interface SsoService for managing access tokens and user logout.

public interface SsoService {
  AccessToken getAccessToken(String token, String clientId);
  String logoutAllClients(String clientId, String userName);
}

SsoServiceImpl Implementation:

@Service("ssoService")
public class SsoServiceImpl implements SsoService {
  @Autowired
  private AccessTokenRepository accessTokenRepository;

  @Autowired
  private ClientRepository clientRepository;

  @Override
  public AccessToken getAccessToken(String token, String clientId) {
    String tokenId = extractTokenId(token);
    return accessTokenRepository.findByTokenIdAndClientId(tokenId, clientId);
  }

  private String extractTokenId(String value) {
    try {
      MessageDigest digest = MessageDigest.getInstance("MD5");
      byte[] bytes = digest.digest(value.getBytes("UTF-8"));
      return String.format("%032x", new BigInteger(1, bytes));
    } catch (Exception e) {
      throw new IllegalStateException("Error extracting token ID", e);
    }
  }

  @Override
  @Transactional
  public String logoutAllClients(String clientId, String userName) {
    requestLogoutToAllClients(userName);
    removeAccessTokens(userName);
    Client client = clientRepository.findOne(clientId);
    return client.getBaseUri();
  }

  private void requestLogoutToAllClients(String userName) {
    List<AccessToken> tokens = accessTokenRepository.findByUserName(userName);
    for (AccessToken token : tokens) {
      requestLogoutToClient(token);
    }
  }

  private void requestLogoutToClient(AccessToken token) {
    Client client = clientRepository.findOne(token.getClientId());
    String logoutUri = client.getLogoutUri();
    Map<String, String> paramMap = new HashMap<>();
    paramMap.put("tokenId", token.getTokenId());
    paramMap.put("userName", token.getUserName());
    // Send HTTP request to logout
  }
}

The getAccessToken() method for retrieving the access token is implemented to handle the following tasks:

  1. Converts the received access token value into an MD5 hash.
  2. Calls the findByTokenIdAndClientId() method of the AccessTokenRepository object with the converted hash value and client ID as arguments to retrieve the AccessToken object stored in the database.
  3. Returns the retrieved AccessToken object.

The logoutAllClients() method, which handles the logout of a user logged in via SSO, performs the following tasks:

  1. Uses the findByUserName() method of the AccessTokenRepository object to retrieve the AccessToken objects issued for the user by their username.
  2. Calls the findOne() method of the ClientRepository with the client ID of the retrieved AccessToken object to get the corresponding Client object.
  3. Sends an HTTP POST request with the token ID and username to the logoutUri of the Client.
  4. Calls the deleteByUserName() method of the AccessTokenRepository object to delete the stored access tokens.

2.1.11. Controller

We implement a controller to manage user info retrieval and logout functionality.

@Controller
public class SsoController {
  @Autowired
  private SsoService ssoService;

  @RequestMapping(value="/userInfo", method=RequestMethod.POST)
  @ResponseBody
  public UserInfoResponse userInfo(@RequestParam(name="token") String token,
      @RequestParam(name="clientId") String clientId) {
    AccessToken accessToken = ssoService.getAccessToken(token, clientId);
    UserInfoResponse response = new UserInfoResponse();
    if (accessToken == null) {
      response.setResult(false);
      response.setMessage("사용자 정보를 조회할 수 없습니다.");
    } else {
      response.setUserName(accessToken.getUserName());
    }
    return response;
  }

  @RequestMapping(value="/userLogout", method=RequestMethod.GET)
  public String userLogout(@RequestParam(name="clientId") String clientId, HttpServletRequest request) {
    String userName = request.getRemoteUser();
    String baseUri = ssoService.logoutAllClients(clientId, userName);
    request.getSession().invalidate();
    return "redirect:" + baseUri;
  }
}

The userInfo() method, which handles retrieving the user information of the client that has been issued an access token, calls the getAccessToken() method of the SsoService to retrieve the AccessToken object. It then sets the result into the UserInfoResponse object and returns it.

The userLogout() method, which handles the client's logout request, calls the logoutAllClients() method of the SsoService and then calls the invalidate() method of the session to invalidate the authenticated session in the user's browser.

2.2. Client System

The items to be implemented in the client system to set up an SSO environment are as follows:

  • Web request handling to redirect to the SSO server's Authorization Endpoint during an SSO request
  • Web request handling for redirection that includes the authorization code after the client authorization process on the SSO server
  • Web request handling for user logout
  • Web request handling for logout on the SSO server

Next, we will look at how these web requests are implemented step by step using Spring MVC.

2.2.1. Redirecting to Authorization Server

@RequestMapping(value="/sso", method=RequestMethod.GET)
public String sso(HttpServletRequest request) {
  //
  String state = UUID.randomUUID().toString();
  request.getSession().setAttribute("oauthState", state);

  StringBuilder builder = new StringBuilder();
  builder.append("redirect:");
  builder.append("http://localhost:8080/oauth/authorize");
  builder.append("?response_type=code");
  builder.append("&client_id=");
  builder.append(getOAuthClientId());
  builder.append("&redirect_uri=");
  builder.append(getOAuthRedirectUri());
  builder.append("&scope=");
  builder.append("read");
  builder.append("&state=");
  builder.append(state);

  return builder.toString();
}

Before redirecting to the SSO server, a random state value is generated and stored in the session with the key "oauthState." After configuring the redirect URI with parameters such as responsetype, clientid, redirect_uri, scope, and state, the constructed redirect URI is returned.

2.2.2. Handling Redirect Web Request with Authorization Code

@RequestMapping(value="/oauthCallback", method=RequestMethod.GET)
public String oauthCallback(@RequestParam(name="code") String code,
    @RequestParam(name="state") String state,
    HttpServletRequest request, ModelMap map) {
  //
  String oauthState = (String)request.getSession().getAttribute("oauthState");
  request.getSession().removeAttribute("oauthState");

  TokenRequestResult tokenRequestResult = null;
  if (oauthState == null || oauthState.equals(state) == false) {
    //
    tokenRequestResult = new TokenRequestResult();
    tokenRequestResult.setError("not matched state");
  }
  else {
    tokenRequestResult = oauthService.requestAccessTokenToAuthServer(code, request);
  }

  if (tokenRequestResult.getError() == null) {
    return "redirect:/me";
  }
  else {
    map.put("result", tokenRequestResult);
    return "authResult";
  }
}

This request, redirected from the SSO server, passes the code and state values as parameters. First, the stored oauthState value in the session is checked against the state value passed from the SSO server. Only if they match, the code and request are passed as parameters to the requestAccessTokenToAuthServer() method in the AuthService.

public TokenRequestResult requestAccessTokenToAuthServer(String code,
    HttpServletRequest request) {
  //
  TokenRequestResult tokenRequestResult = requestAccessTokenToAuthServer(code);

  if (tokenRequestResult.getError() != null) {
    return tokenRequestResult;
  }

  UserInfoResponse userInfoResponse =
  requestUserInfoToAuthServer(tokenRequestResult.getAccessToken());
  if (userInfoResponse.getResult() == false) {
    //
    tokenRequestResult.getError(userInfoResponse.getMessage());
    return tokenRequestResult;
  }

  User user = userService.getUser(userInfoResponse.getUserName());
  request.getSession().setAttribute("user", user);

  userService.updateTokenId(user.getUserName(),
  extractTokenId(tokenRequestResult.getAccessToken()));

  return tokenRequestResult;
}

In the requestAccessTokenToAuthServer() method of AuthService, the request is processed as follows:

  1. The code is passed to the requestAccessTokenToAuthServer() method, which sends a request to the SSO server's token endpoint and receives the result in a TokenRequestResult object.
  2. If there is an error in the request, the TokenRequestResult object is returned immediately.
  3. The access token received from the SSO server's token endpoint is passed to requestUserInfoToAuthServer() to send a request to the User Info endpoint, and the result is stored in a UserInfoResponse object.
  4. If there is an error in this request, an error message is set in the TokenRequestResult object and returned immediately.
  5. The getUser() method of UserService is called to retrieve the User object, which is then stored in the session.
  6. The access token is MD5 hashed and the updateTokenId() method of UserService is called to store it in the database.

Here are the methods that send the requests to the SSO server's token and user info endpoints:

private TokenRequestResult requestAccessTokenToAuthServer(String code) {
  //
  String reqUrl = "http://localhost:8080/oauth/token";
  String authorizationHeader = getAuthorizationRequestHeader();

  Map<String, String> paramMap = new HashMap<>();
  paramMap.put("grant_type", "authorization_code");
  paramMap.put("redirect_uri", getOAuthRedirectUri());
  paramMap.put("code", code);

  HttpPost post = buildHttpPost(reqUrl, paramMap, authorizationHeader);

  TokenRequestResult result = executePostAndParseResult(post, TokenRequestResult.class);

  return result;
}
private User requestUserInfoToAuthServer(String token) {
  //
  String reqUrl = "http://localhost:8080/userInfo";
  String authorizationHeader = null;

  Map<String, String> paramMap = new HashMap<>();
  paramMap.put("token", token);
  paramMap.put("clientId", getOAuthClientId());

  HttpPost post = buildHttpPost(reqUrl, paramMap, authorizationHeader);

  User result = executePostAndParseResult(post, User.class);

  return result;
}

The key point in the definition of these two methods is the setting of the Authorization header for client authentication when sending a request to the token endpoint.

2.2.3. Handling User Logout Web Request

When a logout request is received from the user, the client ID is set as a parameter and a redirect is made to the SSO server's logout endpoint.

@RequestMapping(value="/logout", method=RequestMethod.GET)
public String logout() {
  //
  return "redirect:http://localhost:8080/userLogout?clientId=" + getOAuthClientId();
}

2.2.4. Handling SSO Server Logout Web Request

@RequestMapping(value="/logout", method=RequestMethod.POST)
@ResponseBody
public Response logoutFromAuthServer(
    @RequestParam(name="tokenId") String tokenId,
    @RequestParam(name="userName") String userName) {
  //
  Response response = oauthService.logout(tokenId, userName);
  return response;
}

When a logout request is received from the SSO server, the logout() method of AuthService is called to perform the following tasks:

  1. Retrieve user information.
  2. Check the validity of the access token.
  3. Delete the access token information for the retrieved user.

3. Conclusion

In this section, we have reviewed OAuth 2.0 and described how to extend it for an SSO environment. We also covered how to set up an SSO environment using Spring Boot and Spring Security OAuth.

Source Code

The source code can be found at the following site:
https://github.com/nextreesoft/oauth

References and Sites