I would like to build a RESTful microservices architecture where each microservice acts as a resource server. Additionally, there will be an authorization server responsible only for user registration, authentication, and creation and validation of JWT tokens. The architecture will also include a simple gateway service. Therefore, the OAuth2 authorization code flow is not suitable for me since it is based on redirects (oauth2client gateway with TokenRelay filter -> oauth2authorization server etc.).
So, I have created the following components:
1. Authorization Server
public class SecurityConfig {
private final RsaKeyPairInitializer rsaKeyPairInitializer;
public SecurityFilterChain securityFilterChain(final HttpSecurity http) throws Exception {
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(authorizeRequests -> authorizeRequests
.requestMatchers("/api/auth/**")
.anyRequest().authenticated()
.sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
public AuthenticationManager authenticationManager(final UserDetailsService userDetailsService,
final PasswordEncoder passwordEncoder) {
final var authenticationProvider = new DaoAuthenticationProvider();
authenticationProvider.setUserDetailsService(userDetailsService);
authenticationProvider.setPasswordEncoder(passwordEncoder);
return new ProviderManager(authenticationProvider);
final RsaKeyPair rsaKeyPair = rsaKeyPairInitializer.getRsaKeyPair();
final var rsaKey = new RSAKey.Builder(rsaKeyPair.getPublicKey())
.privateKey(rsaKeyPair.getPrivateKey())
.keyID(rsaKeyPair.getId())
return new JWKSet(rsaKey);
public JwtEncoder jwtEncoder() {
return new NimbusJwtEncoder(new ImmutableJWKSet<>(jwkSet()));
<code>@Configuration
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final RsaKeyPairInitializer rsaKeyPairInitializer;
@Bean
public SecurityFilterChain securityFilterChain(final HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(authorizeRequests -> authorizeRequests
.requestMatchers("/api/auth/**")
.permitAll()
.anyRequest().authenticated()
)
.sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(final UserDetailsService userDetailsService,
final PasswordEncoder passwordEncoder) {
final var authenticationProvider = new DaoAuthenticationProvider();
authenticationProvider.setUserDetailsService(userDetailsService);
authenticationProvider.setPasswordEncoder(passwordEncoder);
return new ProviderManager(authenticationProvider);
}
@Bean
public JWKSet jwkSet() {
final RsaKeyPair rsaKeyPair = rsaKeyPairInitializer.getRsaKeyPair();
final var rsaKey = new RSAKey.Builder(rsaKeyPair.getPublicKey())
.privateKey(rsaKeyPair.getPrivateKey())
.keyID(rsaKeyPair.getId())
.build();
return new JWKSet(rsaKey);
}
@Bean
public JwtEncoder jwtEncoder() {
return new NimbusJwtEncoder(new ImmutableJWKSet<>(jwkSet()));
}
}
</code>
@Configuration
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final RsaKeyPairInitializer rsaKeyPairInitializer;
@Bean
public SecurityFilterChain securityFilterChain(final HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(authorizeRequests -> authorizeRequests
.requestMatchers("/api/auth/**")
.permitAll()
.anyRequest().authenticated()
)
.sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(final UserDetailsService userDetailsService,
final PasswordEncoder passwordEncoder) {
final var authenticationProvider = new DaoAuthenticationProvider();
authenticationProvider.setUserDetailsService(userDetailsService);
authenticationProvider.setPasswordEncoder(passwordEncoder);
return new ProviderManager(authenticationProvider);
}
@Bean
public JWKSet jwkSet() {
final RsaKeyPair rsaKeyPair = rsaKeyPairInitializer.getRsaKeyPair();
final var rsaKey = new RSAKey.Builder(rsaKeyPair.getPublicKey())
.privateKey(rsaKeyPair.getPrivateKey())
.keyID(rsaKeyPair.getId())
.build();
return new JWKSet(rsaKey);
}
@Bean
public JwtEncoder jwtEncoder() {
return new NimbusJwtEncoder(new ImmutableJWKSet<>(jwkSet()));
}
}
<code>@Tag(name = "Authentication management", description = "Endpoint for registration and login")
@RequestMapping(value = "/api/auth")
public class AuthenticationController {
private final AuthenticationService authenticationService;
@PostMapping(value = "/register")
@Operation(summary = "Register a new user", description = "Register a new user")
public UserResponseDto register(@Valid @RequestBody UserRegistrationRequestDto requestDto) {
return authenticationService.register(requestDto);
@PostMapping(value = "/login")
@Operation(summary = "Login a user", description = "Login a user")
public UserLoginResponseDto login(@Valid @RequestBody UserLoginRequestDto requestDto) {
return authenticationService.login(requestDto);
@GetMapping("/oauth2/jwks")
@Operation(summary = "Get jwks", description = "Get jwks configurations")
public Map<String, Object> getJwks() {
return authenticationService.getJwks();
<code>@Tag(name = "Authentication management", description = "Endpoint for registration and login")
@RestController
@RequestMapping(value = "/api/auth")
@RequiredArgsConstructor
public class AuthenticationController {
private final AuthenticationService authenticationService;
@PostMapping(value = "/register")
@Operation(summary = "Register a new user", description = "Register a new user")
public UserResponseDto register(@Valid @RequestBody UserRegistrationRequestDto requestDto) {
return authenticationService.register(requestDto);
}
@PostMapping(value = "/login")
@Operation(summary = "Login a user", description = "Login a user")
public UserLoginResponseDto login(@Valid @RequestBody UserLoginRequestDto requestDto) {
return authenticationService.login(requestDto);
}
@GetMapping("/oauth2/jwks")
@Operation(summary = "Get jwks", description = "Get jwks configurations")
public Map<String, Object> getJwks() {
return authenticationService.getJwks();
}
}
</code>
@Tag(name = "Authentication management", description = "Endpoint for registration and login")
@RestController
@RequestMapping(value = "/api/auth")
@RequiredArgsConstructor
public class AuthenticationController {
private final AuthenticationService authenticationService;
@PostMapping(value = "/register")
@Operation(summary = "Register a new user", description = "Register a new user")
public UserResponseDto register(@Valid @RequestBody UserRegistrationRequestDto requestDto) {
return authenticationService.register(requestDto);
}
@PostMapping(value = "/login")
@Operation(summary = "Login a user", description = "Login a user")
public UserLoginResponseDto login(@Valid @RequestBody UserLoginRequestDto requestDto) {
return authenticationService.login(requestDto);
}
@GetMapping("/oauth2/jwks")
@Operation(summary = "Get jwks", description = "Get jwks configurations")
public Map<String, Object> getJwks() {
return authenticationService.getJwks();
}
}
public class AuthenticationServiceImpl implements AuthenticationService {
private final UserRepository userRepository;
private final UserMapper userMapper;
private final JwtService jwtService;
private final AuthenticationManager authenticationManager;
private final PasswordEncoder passwordEncoder;
private final JWKSet jwkSet;
public UserResponseDto register(final UserRegistrationRequestDto registrationDto) {
validateUserRegistration(registrationDto);
final User userToSave = userMapper.toModel(registrationDto);
userToSave.setPassword(passwordEncoder.encode(registrationDto.password()));
final User savedUser = userRepository.save(userToSave);
return userMapper.toDto(savedUser);
public UserLoginResponseDto login(final UserLoginRequestDto loginDto) {
final var authToken = new UsernamePasswordAuthenticationToken(
final Authentication authentication = authenticationManager.authenticate(authToken);
final String jwt = jwtService.generateToken(authentication);
return new UserLoginResponseDto(jwt);
public Map<String, Object> getJwks() {
return jwkSet.toJSONObject(true);
private void validateUserRegistration(final UserRegistrationRequestDto registrationDto) {
final String email = registrationDto.email();
if (userRepository.existsByEmail(email)) {
throw new RegistrationException("User with email " + email + " already exists");
<code>@Service
@RequiredArgsConstructor
public class AuthenticationServiceImpl implements AuthenticationService {
private final UserRepository userRepository;
private final UserMapper userMapper;
private final JwtService jwtService;
private final AuthenticationManager authenticationManager;
private final PasswordEncoder passwordEncoder;
private final JWKSet jwkSet;
@Override
public UserResponseDto register(final UserRegistrationRequestDto registrationDto) {
validateUserRegistration(registrationDto);
final User userToSave = userMapper.toModel(registrationDto);
userToSave.setPassword(passwordEncoder.encode(registrationDto.password()));
final User savedUser = userRepository.save(userToSave);
return userMapper.toDto(savedUser);
}
@Override
public UserLoginResponseDto login(final UserLoginRequestDto loginDto) {
final var authToken = new UsernamePasswordAuthenticationToken(
loginDto.email(),
loginDto.password()
);
final Authentication authentication = authenticationManager.authenticate(authToken);
final String jwt = jwtService.generateToken(authentication);
return new UserLoginResponseDto(jwt);
}
@Override
public Map<String, Object> getJwks() {
return jwkSet.toJSONObject(true);
}
private void validateUserRegistration(final UserRegistrationRequestDto registrationDto) {
final String email = registrationDto.email();
if (userRepository.existsByEmail(email)) {
throw new RegistrationException("User with email " + email + " already exists");
}
}
}
</code>
@Service
@RequiredArgsConstructor
public class AuthenticationServiceImpl implements AuthenticationService {
private final UserRepository userRepository;
private final UserMapper userMapper;
private final JwtService jwtService;
private final AuthenticationManager authenticationManager;
private final PasswordEncoder passwordEncoder;
private final JWKSet jwkSet;
@Override
public UserResponseDto register(final UserRegistrationRequestDto registrationDto) {
validateUserRegistration(registrationDto);
final User userToSave = userMapper.toModel(registrationDto);
userToSave.setPassword(passwordEncoder.encode(registrationDto.password()));
final User savedUser = userRepository.save(userToSave);
return userMapper.toDto(savedUser);
}
@Override
public UserLoginResponseDto login(final UserLoginRequestDto loginDto) {
final var authToken = new UsernamePasswordAuthenticationToken(
loginDto.email(),
loginDto.password()
);
final Authentication authentication = authenticationManager.authenticate(authToken);
final String jwt = jwtService.generateToken(authentication);
return new UserLoginResponseDto(jwt);
}
@Override
public Map<String, Object> getJwks() {
return jwkSet.toJSONObject(true);
}
private void validateUserRegistration(final UserRegistrationRequestDto registrationDto) {
final String email = registrationDto.email();
if (userRepository.existsByEmail(email)) {
throw new RegistrationException("User with email " + email + " already exists");
}
}
}
public class JwtServiceImpl implements JwtService {
private final JwtEncoder jwtEncoder;
@Value("${jwt.expiration-time}")
private int expirationTime;
public String generateToken(final Authentication authentication) {
final String scope = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(" "));
final Instant now = Instant.now(Clock.system(DEFAULT_ZONE_ID));
final JwtClaimsSet claims = JwtClaimsSet.builder()
.expiresAt(now.plus(expirationTime, ChronoUnit.HOURS))
.subject(authentication.getName())
.claim(ROLES_CLAIM, scope)
return jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
<code>@Service
@RequiredArgsConstructor
public class JwtServiceImpl implements JwtService {
private final JwtEncoder jwtEncoder;
@Value("${jwt.expiration-time}")
private int expirationTime;
@Value("${jwt.issuer}")
private String issuer;
@Override
public String generateToken(final Authentication authentication) {
final String scope = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(" "));
final Instant now = Instant.now(Clock.system(DEFAULT_ZONE_ID));
final JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer(issuer)
.issuedAt(now)
.expiresAt(now.plus(expirationTime, ChronoUnit.HOURS))
.subject(authentication.getName())
.claim(ROLES_CLAIM, scope)
.build();
return jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
}
}
</code>
@Service
@RequiredArgsConstructor
public class JwtServiceImpl implements JwtService {
private final JwtEncoder jwtEncoder;
@Value("${jwt.expiration-time}")
private int expirationTime;
@Value("${jwt.issuer}")
private String issuer;
@Override
public String generateToken(final Authentication authentication) {
final String scope = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(" "));
final Instant now = Instant.now(Clock.system(DEFAULT_ZONE_ID));
final JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer(issuer)
.issuedAt(now)
.expiresAt(now.plus(expirationTime, ChronoUnit.HOURS))
.subject(authentication.getName())
.claim(ROLES_CLAIM, scope)
.build();
return jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
}
}
In short, RSA keys are created or fetched from the database and used to initialize JWKSet and JwtEncoder. The latter encodes JwtClaimsSet and returns a JWT token.
2. Resource Server
All that is needed for configuration is to specify jwk-set-uri (and if necessary to validate the claim iss, then issuer-uri):
issuer-uri: http://localhost:8081
jwk-set-uri: ${spring.security.oauth2.resourceserver.jwt.issuer-uri}/api/auth/oauth2/jwks
<code>spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8081
jwk-set-uri: ${spring.security.oauth2.resourceserver.jwt.issuer-uri}/api/auth/oauth2/jwks
</code>
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8081
jwk-set-uri: ${spring.security.oauth2.resourceserver.jwt.issuer-uri}/api/auth/oauth2/jwks
It is important to note that this endpoint is custom, obtained from the authorization server through this method:
public Map<String, Object> getJwks() {
return jwkSet.toJSONObject(true);
private void validateUserRegistration(final UserRegistrationRequestDto registrationDto) {
final String email = registrationDto.email();
if (userRepository.existsByEmail(email)) {
throw new RegistrationException("User with email " + email + " already exists");
<code>@Override
public Map<String, Object> getJwks() {
return jwkSet.toJSONObject(true);
}
private void validateUserRegistration(final UserRegistrationRequestDto registrationDto) {
final String email = registrationDto.email();
if (userRepository.existsByEmail(email)) {
throw new RegistrationException("User with email " + email + " already exists");
}
}
</code>
@Override
public Map<String, Object> getJwks() {
return jwkSet.toJSONObject(true);
}
private void validateUserRegistration(final UserRegistrationRequestDto registrationDto) {
final String email = registrationDto.email();
if (userRepository.existsByEmail(email)) {
throw new RegistrationException("User with email " + email + " already exists");
}
}
Resource server configs (will be simplified with spring boot 3.3.0+):
public class SecurityConfiguration {
@Value("${system-configuration.public-endpoints}")
private Set<String> publicEndpoints;
public SecurityFilterChain securityFilterChain(final HttpSecurity http) throws Exception {
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(authorizeRequests -> authorizeRequests
.requestMatchers(publicEndpoints.toArray(String[]::new)).permitAll()
.sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.oauth2ResourceServer(oauth -> oauth
.jwt(jwtConfigurer -> jwtConfigurer.jwtAuthenticationConverter(jwtAuthenticationConverter())));
public JwtAuthenticationConverter jwtAuthenticationConverter() {
final var grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
grantedAuthoritiesConverter.setAuthoritiesClaimName("roles");
grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
final var jwtAuthenticationConverter = new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
return jwtAuthenticationConverter;
<code>@Configuration
@EnableMethodSecurity
public class SecurityConfiguration {
@Value("${system-configuration.public-endpoints}")
private Set<String> publicEndpoints;
@Bean
public SecurityFilterChain securityFilterChain(final HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(authorizeRequests -> authorizeRequests
.requestMatchers(publicEndpoints.toArray(String[]::new)).permitAll()
.anyRequest()
.authenticated())
.sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.oauth2ResourceServer(oauth -> oauth
.jwt(jwtConfigurer -> jwtConfigurer.jwtAuthenticationConverter(jwtAuthenticationConverter())));
return http.build();
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
final var grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
grantedAuthoritiesConverter.setAuthoritiesClaimName("roles");
grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
final var jwtAuthenticationConverter = new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
return jwtAuthenticationConverter;
}
}
</code>
@Configuration
@EnableMethodSecurity
public class SecurityConfiguration {
@Value("${system-configuration.public-endpoints}")
private Set<String> publicEndpoints;
@Bean
public SecurityFilterChain securityFilterChain(final HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(authorizeRequests -> authorizeRequests
.requestMatchers(publicEndpoints.toArray(String[]::new)).permitAll()
.anyRequest()
.authenticated())
.sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.oauth2ResourceServer(oauth -> oauth
.jwt(jwtConfigurer -> jwtConfigurer.jwtAuthenticationConverter(jwtAuthenticationConverter())));
return http.build();
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
final var grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
grantedAuthoritiesConverter.setAuthoritiesClaimName("roles");
grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
final var jwtAuthenticationConverter = new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
return jwtAuthenticationConverter;
}
}
@RequestMapping(value = "/api/users")
@Tag(name = "User management", description = "Endpoint for managing users")
public class UserController {
private final UserService userService;
@GetMapping(value = "/me")
@Operation(summary = "Get current user", description = "Get current user")
public UserDto getCurrentUser() {
return userService.getCurrentUser();
<code>@RestController
@RequestMapping(value = "/api/users")
@RequiredArgsConstructor
@Tag(name = "User management", description = "Endpoint for managing users")
public class UserController {
private final UserService userService;
@GetMapping(value = "/me")
@Operation(summary = "Get current user", description = "Get current user")
public UserDto getCurrentUser() {
return userService.getCurrentUser();
}
}
</code>
@RestController
@RequestMapping(value = "/api/users")
@RequiredArgsConstructor
@Tag(name = "User management", description = "Endpoint for managing users")
public class UserController {
private final UserService userService;
@GetMapping(value = "/me")
@Operation(summary = "Get current user", description = "Get current user")
public UserDto getCurrentUser() {
return userService.getCurrentUser();
}
}
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
private final UserMapper userMapper;
public User getUserByEmail(final String email) {
return userRepository.findByEmail(email)
.orElseThrow(() -> new EntityNotFoundException("User with email: " + email + " was not found"));
public UserDto getCurrentUser() {
final String email = SecurityContextHolder.getContext().getAuthentication().getName();
return userMapper.toDto(getUserByEmail(email));
<code>@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
private final UserMapper userMapper;
@Override
public User getUserByEmail(final String email) {
return userRepository.findByEmail(email)
.orElseThrow(() -> new EntityNotFoundException("User with email: " + email + " was not found"));
}
@Override
public UserDto getCurrentUser() {
final String email = SecurityContextHolder.getContext().getAuthentication().getName();
return userMapper.toDto(getUserByEmail(email));
}
}
</code>
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
private final UserMapper userMapper;
@Override
public User getUserByEmail(final String email) {
return userRepository.findByEmail(email)
.orElseThrow(() -> new EntityNotFoundException("User with email: " + email + " was not found"));
}
@Override
public UserDto getCurrentUser() {
final String email = SecurityContextHolder.getContext().getAuthentication().getName();
return userMapper.toDto(getUserByEmail(email));
}
}
3. Gateway Service
- id: authorization-service
uri: ${system-configuration.endpoints.authorization-service}
uri: ${system-configuration.endpoints.user-service}
<code>spring:
cloud:
gateway:
routes:
- id: authorization-service
uri: ${system-configuration.endpoints.authorization-service}
predicates:
- Path=/api/auth/**
- id: user-service
uri: ${system-configuration.endpoints.user-service}
predicates:
- Path=/api/users/**
</code>
spring:
cloud:
gateway:
routes:
- id: authorization-service
uri: ${system-configuration.endpoints.authorization-service}
predicates:
- Path=/api/auth/**
- id: user-service
uri: ${system-configuration.endpoints.user-service}
predicates:
- Path=/api/users/**
Question: Is the security implemented correctly according to this approach? I am concerned that JwtDecoder is created based on JWKSet with a public key. Am I correct in understanding that the token can be forged and the resource server will accept it?