The main components you need for an authorization server to work properly:
- Configuration filter for protocol endpoints — defines configs specific to the authorization server’s capabilities, including customizations.
- Authentication configuration filter — like any Spring Security app, defines authentication and authorization configuration, plus other security mechanisms like CORS and CSRF.
- User details management — established through a
UserDetailsServicebean and aPasswordEncoder, as with any authentication process in Spring Security. - Client details management — the authorization server uses a
RegisteredClientRepositoryto manage client credentials and other details. - 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.
- General app settings —
AuthorizationServerSettingsconfigures 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:
- Check the endpoints the authorization server exposes.
- Use the authorization endpoint to get an authorization code.
- 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=S256Once 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_6x4LsVglQIThe request params:
client_id— identifies the clientredirect_uri— the URI the authorization server sent the code to after successful authenticationgrant_type=authorization_code— the flow the client uses to request the access tokencode— the authorization code value the server providedcode_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:
users [id, username, password, authority]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):
// UserRepositorypublic 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:
@Servicepublic 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:
// SecurityUserpublic 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@Transactionalpublic 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:
- For user details, implement a
UserDetailsService, same as any other web app. - 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.