Skip to content
thesarfo

Concept

Spring Security: OAuth2 Authorization Server

The main components of a Spring Authorization Server, an in-memory implementation, the authorization code grant flow, and swapping in-memory user/client management for a database.

views 0

The main components you need for an authorization server to work properly:

  1. Configuration filter for protocol endpoints — defines configs specific to the authorization server’s capabilities, including customizations.
  2. Authentication configuration filter — like any Spring Security app, defines authentication and authorization configuration, plus other security mechanisms like CORS and CSRF.
  3. User details management — established through a UserDetailsService bean and a PasswordEncoder, as with any authentication process in Spring Security.
  4. Client details management — the authorization server uses a RegisteredClientRepository to manage client credentials and other details.
  5. Key-pair management (used to sign/validate tokens) — when using non-opaque tokens, the server signs with a private key and exposes a public key resource servers use to validate tokens. Managed through a “key source” component.
  6. General app settingsAuthorizationServerSettings configures generic customizations like which endpoints the app exposes.

A simple implementation:

@Configuration
@Order(1)
public class SecurityConfig {
/* This is the first filterchain/config for the oauth2 config */
@Bean
@Order(1)
public SecurityFilterChain authServerFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); /* calling the utility method to apply default configurations for the authorization server endpoints */
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.oidc(Customizer.withDefaults()); /* enabling the openid connect protocol */
http.exceptionHandling(
e -> e.authenticationEntryPoint(
new LoginUrlAuthenticationEntryPoint("/login") /* specify the authentication page for users */
)
);
return http.build();
}
/* This will configure authentication and authorization like how you will normally do it for your app */
@Bean
@Order(2)
public SecurityFilterChain appFilterChain(HttpSecurity http) throws Exception {
http.formLogin(Customizer.withDefaults());
http
.authorizeHttpRequests(
auth -> auth
.anyRequest().authenticated()
);
return http.build();
}
@Bean
public UserDetailsService userDetailsService() {
var u1 = User.withUsername("user")
.password("password")
.authorities("read")
.build();
return new InMemoryUserDetailsManager(u1);
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
/* When using oauth2, you manage client details, and just like how you would have a userdetailsservice for userdetails, you will need a registeredclientrepository for client details */
@Bean
public RegisteredClientRepository registeredClientRepository() {
RegisteredClient r1 = RegisteredClient
.withId(UUID.randomUUID().toString())
.clientId("client")
.clientSecret("secret")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("https://www.manning.com/authorize")
.scope(OidcScopes.OPENID)
.build();
return new InMemoryRegisteredClientRepository(r1);
}
/* If the authorization server uses non-opaque tokens, it uses a private key to sign the tokens and provides clients a public key that can be used to validate the tokens' authenticity. JWKSource is the object that provides key management for the Spring Security Authorization server */
@Bean
public JWKSource<SecurityContext> jwkSource()
throws NoSuchAlgorithmException {
KeyPairGenerator keyPairGenerator =
KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(2048);
KeyPair keyPair = keyPairGenerator.generateKeyPair();
RSAPublicKey publicKey =
(RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey =
(RSAPrivateKey) keyPair.getPrivate();
RSAKey rsaKey = new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString())
.build();
JWKSet jwkSet = new JWKSet(rsaKey);
return new ImmutableJWKSet<>(jwkSet);
}
/* Lastly, the generic authorization server settings, letting you customize all the endpoint paths the server exposes */
@Bean
public AuthorizationServerSettings authorizationServerSettings() {
return AuthorizationServerSettings
.builder()
.build();
}
}

Running the authorization code grant type

Using the registered client details, we follow the authorization code flow to get an access token:

  1. Check the endpoints the authorization server exposes.
  2. Use the authorization endpoint to get an authorization code.
  3. Use the authorization code to get an access token.

The defaults can be found at http://localhost:8080/.well-known/openid-configuration. A URL where the client redirects the user to authorize looks like:

http://localhost:8080/oauth2/authorize?response_type=code&client_id=client&scope=openid&redirect_uri=https://springone.io/authorized&code_challenge=QYPAZ5NU8yvtlQ9erXrUYR-T5AGCjCF47vN-KsaI2A8&code_challenge_method=S256

Once the user logs in, an authorization code is generated (in the code= part of the browser URL). The client uses that code to request an access token:

http://localhost:8080/oauth2/token?client_id=userclient&redirect_uri=https://springone.io/authorized&grant_type=authorization_code&code=dWlJMGpGlUAPz0sRU1y8suXDyWejo0_B4-WrLP-ks5kSlcdvlGG-u1OxOORvvpm7IMJaC_lMqzTX2Oh6AKHGOb2J4-Hp6PVPvGjLeUQMnWzz6h3Xyy1D0S6czbiTeU8f&code_verifier=qPsH306-ZDDaOE8DFzVn05TkN3ZZoVmI_6x4LsVglQI

The request params:

  1. client_id — identifies the client
  2. redirect_uri — the URI the authorization server sent the code to after successful authentication
  3. grant_type=authorization_code — the flow the client uses to request the access token
  4. code — the authorization code value the server provided
  5. code_verifier — the verifier the challenge sent at authorization was created from

Backing the authorization server with a database

The in-memory implementation above keeps user and client management in memory. For a real app, you’d query/manage them from a database — a UserDetailsService database implementation, and a RegisteredClientRepository database implementation, with a real password encoder for both user passwords and client secrets.

Two tables:

  1. users [id, username, password, authority]
  2. clients [id, client_id, secret, scope, auth_method, grant_type, redirect_uri]

Entities for both:

// User
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
private String username;
private String password;
private String authority;
// getters and setters
}
// Client
@Entity
@Table(name = "clients")
public class Client {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
private String clientId;
private String secret;
private String redirectUri;
private String scope;
private String authMethod;
private String grantType;
// getters and setters
}

Repositories — since we’re implementing our own, both need custom lookup methods (findByUsername for users, findByClientId for clients):

// UserRepository
public interface UserRepository extends JpaRepository<User, Integer> {
@Query("SELECT u FROM User u WHERE u.username = :username")
Optional<User> findByUsername(String username);
}
public interface ClientRepository extends JpaRepository<Client, Integer> {
@Query("SELECT c FROM Client c WHERE c.clientId = :clientId")
Optional<Client> findByClientId(String clientId);
}

Custom UserDetailsService:

@Service
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public CustomUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) {
Optional<User> user = userRepository.findByUsername(username);
return user.map(SecurityUser::new).
orElseThrow(() -> new UsernameNotFoundException("User not found"));
}
}

loadUserByUsername returns a UserDetails, so we need an implementation — a wrapper around our User entity, SecurityUser:

// SecurityUser
public class SecurityUser implements UserDetails {
private final User user;
public SecurityUser(User user) {
this.user = user;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority(user.getAuthority()));
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}

With our own CustomUserDetailsService, we can delete the in-memory UserDetailsService bean. Same story for the client side — a CustomClientService reading from the DB instead:

// Custom Client Service
@Service
@Transactional
public class CustomClientService implements RegisteredClientRepository {
private final ClientRepository clientRepository;
public CustomClientService(ClientRepository clientRepository) {
this.clientRepository = clientRepository;
}
@Override
public void save(RegisteredClient registeredClient) {
clientRepository.save(Client.from(registeredClient));
}
@Override
public RegisteredClient findById(String id) {
var client =
clientRepository.findById(Integer.valueOf(id))
.orElseThrow();
return Client.from(client);
}
@Override
public RegisteredClient findByClientId(String clientId) {
var client =
clientRepository.findByClientId(clientId)
.orElseThrow();
return Client.from(client);
}
}

Then point your datasource connections in application.properties.

In conclusion

The Spring Authorization Server framework helps you build a custom OAuth 2/OpenID Connect authorization server from scratch. Since it manages user and client details, you implement the components defining how the app collects that data:

  1. For user details, implement a UserDetailsService, same as any other web app.
  2. For client details, implement a RegisteredClientRepository.

You can register clients using various grant types — preferably the same client shouldn’t mix user-dependent flows (like authorization code) with user-independent ones (like client credentials).

When using non-opaque tokens (usually JWTs), configure the key pairs used to sign tokens via a JWKSource. When using opaque tokens (which carry no data), the resource server must use the introspection endpoint to verify validity and collect the data needed for authorization.

If you need to invalidate already-issued tokens, the authorization server offers a revocation endpoint — and when using revocation, the resource server must always introspect tokens (even non-opaque ones) to verify validity.