Overview
OpenID Connect is an open standard published by the OpenID Foundation in February 2014. It defines an interworking way to perform user authentication using OAuth 2.0. OpenID Connect builds directly on OAuth 2.0 and remains compatible with it.
When an authorization server supports OIDC, it is sometimes called an Identity Provider (Idp), because it provides information to client about the owner of a resource. And the client is also referred to as the Relying Party (RP) in the OpenID Connect process.
The OpenID Connect flow looks the same as OAuth2. The main difference is that in the authorization request, a specific scope openid
is used, while in the obtain token, the login relying party (RP) receives both an access token and an ID token (signed JWT). ID token differ from access token in that ID token are sent to and parsed by the login relying party (RP).
In this article you will learn:
- Configure the authorization service to support OpenID Connect
- Custom id token
- The login relying party implements permission mapping through
OAuth2UserService
Prerequisites:
- java 8+
- MySQL
Use Spring Authorization Server to build identity provider server (IdP) ๐
In this section, we will use Spring Authorization Server to build an identity provider service, and implement a custom ID token through OAuth2TokenCustomizer
.
Maven dependencies
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>2.6.7</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-authorization-server</artifactId>
<version>0.3.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.6.7</version>
</dependency>
Configuration
First we configure the identity provider service port 8080:
server:
port: 8080
Next we create the AuthorizationServerConfig
configuration class, in which we configure OAuth2 and OICD related beans. We first register a client:
@Bean
public RegisteredClientRepository registeredClientRepository() {
RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("relive-client")
.clientSecret("{noop}relive-client")
.clientAuthenticationMethods(s -> {
s.add(ClientAuthenticationMethod.CLIENT_SECRET_POST);
s.add(ClientAuthenticationMethod.CLIENT_SECRET_BASIC);
})
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.redirectUri("http://127.0.0.1:8070/login/oauth2/code/messaging-client-oidc")
.scope(OidcScopes.OPENID)
.scope(OidcScopes.PROFILE)
.scope(OidcScopes.EMAIL)
.clientSettings(ClientSettings.builder()
.requireAuthorizationConsent(true)
.requireProofKey(false)
.build())
.tokenSettings(TokenSettings.builder()
.accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED)
.idTokenSignatureAlgorithm(SignatureAlgorithm.RS256)
.accessTokenTimeToLive(Duration.ofSeconds(30 * 60))
.refreshTokenTimeToLive(Duration.ofSeconds(60 * 60))
.reuseRefreshTokens(true)
.build())
.build();
return new InMemoryRegisteredClientRepository(registeredClient);
}
The properties we are configuring are:
- clientId -- The identity provider will use this to identify which client is trying to access the resource
- clientSecret -- A secret known to both client and identity provider service, which provides trust between the two
- clientAuthenticationMethod -- Client authentication method, in our example, we will support basic and post authentication methods
- authorizationGrantType -- Grant type, support authorization code and refresh token
- redirectUri -- The redirection URI, which the client will use in the redirection-based flow
- scope -- This parameter defines the permissions the client may have. In our case, we will have the required openid and profile, emali, to fetch additional identity information
OpenID Connect uses a special permission scope value openid to control access to the UserInfo endpoint. OpenID Connect defines a set of standardized OAuth2 permission scopes, corresponding to the subset of user attributes profile, email, phone, address, see the table:
scope of authority | statement |
---|---|
openid | sub |
profile | Name, family_name, given_name, middle_name, nickname, preferred_username, profile, picture, website, gender, birthdate, zoneinfo, locale, updated_at |
email, email_verified | |
address | address is a JSON object, contains formatted, street_address, locality, region, postal_code, country |
phone | phone_number, phone_number_verified |
Let's define OidcUserInfoService
according to the above specification, which is used to extend /userinfo user information endpoint response:
public class OidcUserInfoService {
public OidcUserInfo loadUser(String name, Set<String> scopes) {
OidcUserInfo.Builder builder = OidcUserInfo.builder().subject(name);
if (!CollectionUtils.isEmpty(scopes)) {
if (scopes.contains(OidcScopes.PROFILE)) {
builder.name("First Last")
.givenName("First")
.familyName("Last")
.middleName("Middle")
.nickname("User")
.preferredUsername(name)
.profile("http://127.0.0.1:8080/" + name)
.picture("http://127.0.0.1:8080/" + name + ".jpg")
.website("http://127.0.0.1:8080/")
.gender("female")
.birthdate("2022-05-24")
.zoneinfo("China/Beijing")
.locale("zh-cn")
.updatedAt(LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE));
}
if (scopes.contains(OidcScopes.EMAIL)) {
builder.email(name + "@outlook.com").emailVerified(true);
}
if (scopes.contains(OidcScopes.ADDRESS)) {
JSONObject address = new JSONObject();
address.put("address", Collections.singletonMap("formatted", "Champ de Mars\n5 Av. Anatole France\n75007 Paris\nFrance"));
builder.address(address.toJSONString());
}
if (scopes.contains(OidcScopes.PHONE)) {
builder.phoneNumber("13028903134").phoneNumberVerified("false");
}
}
return builder.build();
}
}
Next, we'll configure a bean to apply default OAuth2 security. Use the above OidcUserInfoService
to configure UserInfoMapper in OIDC. oauth2ResourceServer() configures the resource server to use JWT authentication to secure the /userinfo endpoint provided by Spring Security. For unauthenticated requests we redirect it to the /login login page.
Note: Sometimes the "authorization server" and "resource server" are the same server.
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfigurer<HttpSecurity> authorizationServerConfigurer =
new OAuth2AuthorizationServerConfigurer<>();
//custom User Mapper
Function<OidcUserInfoAuthenticationContext, OidcUserInfo> userInfoMapper = (context) -> {
OidcUserInfoAuthenticationToken authentication = context.getAuthentication();
JwtAuthenticationToken principal = (JwtAuthenticationToken) authentication.getPrincipal();
return userInfoService.loadUser(principal.getName(), context.getAccessToken().getScopes());
};
authorizationServerConfigurer.oidc((oidc) -> {
oidc.userInfoEndpoint((userInfo) -> userInfo.userInfoMapper(userInfoMapper));
});
RequestMatcher endpointsMatcher = authorizationServerConfigurer.getEndpointsMatcher();
return http.requestMatcher(endpointsMatcher).authorizeRequests((authorizeRequests) -> {
((ExpressionUrlAuthorizationConfigurer.AuthorizedUrl) authorizeRequests.anyRequest()).authenticated();
}).csrf((csrf) -> {
csrf.ignoringRequestMatchers(new RequestMatcher[]{endpointsMatcher});
}).apply(authorizationServerConfigurer)
.and()
.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt)
.exceptionHandling(exceptions -> exceptions.
authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login")))
.apply(authorizationServerConfigurer)
.and()
.build();
}
Each authorization server needs its signing key for tokens, let's generate a 2048 byte RSA key.
@Bean
public JWKSource<SecurityContext> jwkSource() {
RSAKey rsaKey = Jwks.generateRsa();
JWKSet jwkSet = new JWKSet(rsaKey);
return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
}
static class Jwks {
private Jwks() {
}
public static RSAKey generateRsa() {
KeyPair keyPair = KeyGeneratorUtils.generateRsaKey();
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
return new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString())
.build();
}
}
static class KeyGeneratorUtils {
private KeyGeneratorUtils() {
}
static KeyPair generateRsaKey() {
KeyPair keyPair;
try {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(2048);
keyPair = keyPairGenerator.generateKeyPair();
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
return keyPair;
}
}
We will then enable the Spring Web Security module using a configuration class annotated with @EnableWebSecurity
.
@Configuration(proxyBeanMethods = false)
@EnableWebSecurity
public class DefaultSecurityConfig {
@Bean
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeRequests(authorizeRequests ->
authorizeRequests.anyRequest().authenticated()
)
.formLogin(withDefaults());
return http.build();
}
//...
}
Here we use Form authentication, so we also need to provide username and password for login authentication.
@Bean
UserDetailsService users() {
UserDetails user = User.withDefaultPasswordEncoder()
.username("admin")
.password("password")
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(user);
}
We will change the ID Token claim and add the user role attribute to pass the role information to the client.
@Configuration(proxyBeanMethods = false)
public class IdTokenCustomizerConfig {
@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> tokenCustomizer() {
return (context) -> {
if (OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) {
context.getClaims().claims(claims ->
claims.put("role", context.getPrincipal().getAuthorities()
.stream().map(GrantedAuthority::getAuthority)
.collect(Collectors.toSet())));
}
};
}
}
Relying Party Service (RP) Implementation ๐
In this section, we will use Spring Security to build the relying party service, and design the relevant database table structure to express the permission relationship between the associated identity provider service and the relying party service, and implement permission mapping through OAuth2UserService
.
Part of the code in this section involves JPA-related knowledge. If you donโt understand it, it doesnโt matter. You can replace it with Mybatis.
Maven dependencies
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.6.7</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
<version>2.6.7</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jdbc</artifactId>
<version>2.6.7</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
<version>2.6.7</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
<version>2.6.7</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.21</version>
</dependency>
Relevant database table structure
This is the relevant database table used by the RP service in this article, and the SQL statements related to creating tables and initializing data can be obtained from here.
Configuration
First, we configure the service port and database connection information in the application.yml
file.
server:
port: 8070
servlet:
session:
cookie:
name: CLIENT-SESSION
spring:
datasource:
druid:
db-type: mysql
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/oidc_login?createDatabaseIfNotExist=true&useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: <<root>>
password: <<password>>
Next we will enable the Spring Security configuration. Use Form authentication, and use oauth2Login() to define the default configuration of OAuth2 login.
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests()
.anyRequest()
.authenticated()
.and()
.formLogin(from -> {
from.defaultSuccessUrl("/home");
})
.oauth2Login(Customizer.withDefaults())
.csrf().disable();
return http.build();
}
Next, we will configure the storage method of the OAuth2 client based on the MySql database. You can also learn more about it from the Spring Security Persistent OAuth2 client.
/**
* Define the JDBC client registration repository
*
* @param jdbcTemplate
* @return
*/
@Bean
public ClientRegistrationRepository clientRegistrationRepository(JdbcTemplate jdbcTemplate) {
return new JdbcClientRegistrationRepository(jdbcTemplate);
}
/**
* Responsible for {@link OAuth2AuthorizedClient} persistence between web requests
*
* @param jdbcTemplate
* @param clientRegistrationRepository
* @return
*/
@Bean
public OAuth2AuthorizedClientService authorizedClientService(
JdbcTemplate jdbcTemplate,
ClientRegistrationRepository clientRegistrationRepository) {
return new JdbcOAuth2AuthorizedClientService(jdbcTemplate, clientRegistrationRepository);
}
/**
* OAuth2AuthorizedClientRepository is a container class for saving and persisting authorized clients between requests
*
* @param authorizedClientService
* @return
*/
@Bean
public OAuth2AuthorizedClientRepository authorizedClientRepository(
OAuth2AuthorizedClientService authorizedClientService) {
return new AuthenticatedPrincipalOAuth2AuthorizedClientRepository(authorizedClientService);
}
We are no longer using memory-based username and password. We have added the username and password to the user table when initializing the database, so we need to implement the UserDetailsService
interface to obtain user information during Form authentication.
@RequiredArgsConstructor
public class JdbcUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
com.relive.entity.User user = userRepository.findUserByUsername(username);
if (ObjectUtils.isEmpty(user)) {
throw new UsernameNotFoundException("user is not found");
}
if (CollectionUtils.isEmpty(user.getRoleList())) {
throw new UsernameNotFoundException("role is not found");
}
Set<SimpleGrantedAuthority> authorities = user.getRoleList().stream().map(Role::getRoleCode)
.map(SimpleGrantedAuthority::new).collect(Collectors.toSet());
return new User(user.getUsername(), user.getPassword(), authorities);
}
}
Here UserRepository
extends JpaRepository
and provides database operations for user tables. The detailed code can be obtained from the link at the end of the article.
Now we will solve how to map the IdP service user role to the existing role of the RP service. In the previous article, GrantedAuthoritiesMapper
was used to map the role. In this article we will use OAuth2UserService
to add role mapping strategy, which is more flexible than GrantedAuthoritiesMapper
.
public class OidcRoleMappingUserService implements OAuth2UserService<OidcUserRequest, OidcUser> {
private OidcUserService oidcUserService;
private final OAuth2ClientRoleRepository oAuth2ClientRoleRepository;
//...
@Override
public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException {
OidcUser oidcUser = oidcUserService.loadUser(userRequest);
OidcIdToken idToken = userRequest.getIdToken();
List<String> role = idToken.getClaimAsStringList("role");
Set<SimpleGrantedAuthority> mappedAuthorities = role.stream()
.map(r -> oAuth2ClientRoleRepository.findByClientRegistrationIdAndRoleCode(userRequest.getClientRegistration().getRegistrationId(), r))
.map(OAuth2ClientRole::getRole).map(Role::getRoleCode).map(SimpleGrantedAuthority::new)
.collect(Collectors.toSet());
oidcUser = new DefaultOidcUser(mappedAuthorities, oidcUser.getIdToken(), oidcUser.getUserInfo());
return oidcUser;
}
}
Finally, we will create a HomeController
to make the test effect more visually significant by controlling the content displayed on the page. We will display different information according to the role and use the thymeleaf template engine to render.
@Controller
public class HomeController {
private static Map<String, List<String>> articles = new HashMap<>();
static {
articles.put("ROLE_OPERATION", Arrays.asList("Java"));
articles.put("ROLE_SYSTEM", Arrays.asList("Java", "Python", "C++"));
}
@GetMapping("/home")
public String home(Authentication authentication, Model model) {
String authority = authentication.getAuthorities().iterator().next().getAuthority();
model.addAttribute("articles", articles.get(authority));
return "home";
}
}
After completing the configuration, we can visit http://127.0.0.1:8070/home for testing.
Conclusion
In this article, Spring Security's support for OpenID Connect is shared. As always, the source code used in this article is available on GitHub.
Thanks for reading! ๐
Top comments (0)