DEV Community

Nermin Karapandzic
Nermin Karapandzic

Posted on

Spring security new Authorization server (0.3.1) - part 3

In this part we will continue building the server and specifically we will change the in memory client to a database, persistent solution.

First let's start with the entities, you can inspect the RegisteredClient class provided by the library which is used by RegisteredClientRepository to get an idea how we need to model our entity.

Here are the fields on the RegisteredClient class:

  • id (String)
  • clientId (String) can be the same field as id
  • clientIdIssuedAt (Instant) - not included in our entity
  • clientSecret (String)
  • clientSecretExpiresAt (Instant) - Not included in our entity
  • clientName (String) - Not included in our entity
  • clientAuthenticationMethods (Set)
  • authorizationGrantTypes (Set)
  • redirectUris (Set)
  • scopes (Set)
  • clientSettings (ClientSettings) - Not included in our entity
  • tokenSettings (TokenSettings) - Not included in our entity

Based on this you can model your client entity however you want but in the end you have to be able to construct a RegisteredClient from it. Since we don't have an interface here as we had for the UserDetails, we cannot simply implement the interface and say this entity is the RegisteredClient, but instead what I did is provide a factory class that builds a RegisteredClient from an entity.

Client entity:

@Entity
@Getter
@Setter
@NoArgsConstructor
public class Client {

  @Id
  private String id;
  private String secret;
  @ElementCollection(fetch = FetchType.EAGER)
  private Set<AuthorizationGrantType> grantTypes;
  private ClientAuthenticationMethod authenticationMethod;
  @OneToMany(mappedBy = "client", fetch = FetchType.EAGER)
  private Set<ClientRedirectUrl> redirectUris;
  @ManyToMany(cascade = CascadeType.PERSIST, fetch = FetchType.EAGER)
  @JoinTable(name = "client_scope_mapping",
    joinColumns = @JoinColumn(name = "client_id", referencedColumnName = "id"),
    inverseJoinColumns = @JoinColumn(name = "scope_id", referencedColumnName = "id")
  )
  private Collection<ClientScope> scopes;

  public Client(CreateClientRequest request) {
    this.id = request.getId();
    this.secret = request.getSecret();
    this.grantTypes = request.getGrantTypes();
    this.authenticationMethod = request.getAuthenticationMethod();
  }

  public static RegisteredClient toRegisteredClient(Client client) {
    RegisteredClient.Builder builder = RegisteredClient.withId(client.getId())
      .clientId(client.getId())
      .clientSecret(client.getSecret())
      .clientAuthenticationMethod(client.getAuthenticationMethod())
      .authorizationGrantTypes(
          authorizationGrantTypes -> authorizationGrantTypes.addAll(client.getGrantTypes()))
      .redirectUris(
          redirectUris -> redirectUris.addAll(client.getRedirectUris()
              .stream()
              .map(ClientRedirectUrl::getUrl)
              .collect(Collectors.toSet())))
      .scopes(scopes -> scopes.addAll(client.getScopes()
          .stream()
          .map(ClientScope::getScope)
          .collect(Collectors.toSet())));
    return builder.build();
  }

}
Enter fullscreen mode Exit fullscreen mode

You see I decided to store the grant types for each client as an @ElementCollection which will store the serialized set of AuthorizationGrantType in the database as tinyblob, you could choose to have a separate table to hold all available grant types, and then just join the ones you need for each client, you'd probably need a join table as well.

Then we have @OneToMany with redirectUris (ClientRedirectUrl) and @ManyToMany with scopes (ClientScope).

ClientRedirectUrl entity:

@Entity
@Getter
@NoArgsConstructor
@Table(name = "client_redirect_uri")
public class ClientRedirectUrl {
  @Id
  private String id;
  private String url;
  @Setter
  @ManyToOne
  @JoinColumn(name = "client_id", referencedColumnName = "id")
  private Client client;

  public ClientRedirectUrl(String url, Client client) {
    this.id = RandomStringUtils.randomAlphanumeric(10);
    this.url = url;
    this.client = client;
  }
}
Enter fullscreen mode Exit fullscreen mode

ClientScope entity:

@Entity
@Getter
@NoArgsConstructor
@Setter
@Table(name = "client_scope")
public class ClientScope {

  @Id
  private String id;
  private String scope;

  public ClientScope(String scope) {
    this.id = RandomStringUtils.randomAlphanumeric(10);
    this.scope = scope;
  }
}
Enter fullscreen mode Exit fullscreen mode

This is the final schema diagram:

Schema diagram

Next, in the Oauth2Config class, which we created in the first part, we need to remove the bean that provides an in memory client repository. Instead we will provide a @Service which will implement the RegisteredClientRepository.

ClientService:

@Service
@RequiredArgsConstructor
public class ClientService implements RegisteredClientRepository {

  private final ClientRepository clientRepository;
  private final ClientRedirectUrlRepository clientRedirectUrlRepository;
  private final ClientScopeRepository clientScopeRepository;

  @Override
  public void save(RegisteredClient registeredClient) {
  }

  @Override
  public RegisteredClient findById(String id) {
    var client = this.clientRepository.findById(id).orElseThrow();
    return Client.toRegisteredClient(client);
  }

  @Override
  public RegisteredClient findByClientId(String clientId) {
    var client = this.clientRepository.findById(clientId).orElseThrow();
    return Client.toRegisteredClient(client);
  }

  public ClientResponse createClient(CreateClientRequest request) {
    var scopes = request.getScopes().stream().map(ClientScope::new).collect(Collectors.toSet());
    scopes.forEach(this.clientScopeRepository::save);

    var client = new Client(request);
    client.setScopes(scopes);
    this.clientRepository.save(client);

    client.setRedirectUris(request.getRedirectUris().stream()
        .map(url -> new ClientRedirectUrl(url, client))
        .collect(Collectors.toSet()));
    client.getRedirectUris().forEach(this.clientRedirectUrlRepository::save);

    return new ClientResponse(client);
  }
}
Enter fullscreen mode Exit fullscreen mode

And now this is how the Oauth2Config class looks like:

@Configuration
@RequiredArgsConstructor
public class Oauth2Config {

  private final ClientService clientService;

  @Bean
  @Order(HIGHEST_PRECEDENCE)
  public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http)
      throws Exception {
    OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);

    http
        // Redirect to the login page when not authenticated from the
        // authorization endpoint
        .exceptionHandling((exceptions) -> exceptions
            .authenticationEntryPoint(
                new LoginUrlAuthenticationEntryPoint("/login"))
        );


    return http.build();
  }

  @Bean
  public JWKSource<SecurityContext> jwkSource() {
    KeyPair keyPair = generateRsaKey();
    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);
  }

  private 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;
  }

  @Bean
  public ProviderSettings providerSettings() {
    return ProviderSettings.builder().build();
  }

}
Enter fullscreen mode Exit fullscreen mode

I also created and endpoint for creating a new client, and had to also allow requests to it in the default config, so it now looks like this:

@Configuration
@RequiredArgsConstructor
public class SecurityConfig {

  private final UserService userService;

  @Bean
  public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http)
      throws Exception {

    http
        .authorizeRequests()
        .antMatchers("/users/**").permitAll()
        .antMatchers("/clients/**").permitAll()
        .anyRequest().authenticated()
        .and()
        .csrf().ignoringAntMatchers("/users/**", "/clients/**")
        .and()
        .formLogin(Customizer.withDefaults());

    return http.build();
  }

  @Bean
  public AuthenticationManager authenticationManagerBean() throws Exception {
    var provider = new DaoAuthenticationProvider();
    provider.setUserDetailsService(userService);
    provider.setPasswordEncoder(NoOpPasswordEncoder.getInstance()); //temporary
    return new ProviderManager(provider);
  }

  @Bean
  public UserDetailsService userDetailsService() {
    return this.userService;
  }

}
Enter fullscreen mode Exit fullscreen mode

Maybe it's not a great idea to permitAll for the wildcard as in my case, but again you can modify this to your needs.

Let's test the authorization again, first create a client:

Create client response

Then, create a user:

Create user response

Then authorize,
http://localhost:8080/oauth2/authorize?response_type=code&client_id=client&scope=openid&redirect_uri=http://spring.io&code_challenge=YHRCg0i58poWtvPg_xiSHFBqCahjxCqTyUbhYTAk5ME&code_challenge_method=S256

use your own code_challenge, you will need a verifier.

Get the token:

Token response

It works, there are some obvious things that should probably be changed like the authorization matchers configuration, or the password encoder or the lack of one, but you get the point.

You can find the full code here, but a fair warning it will probably change in the future as I will be looking to build an open source version out of it.

Top comments (1)

Collapse
 
xan1997 profile image
xan-1997

Hi thanks for blog, how should we register client repositories in oautn config in my opinion you forgot registering ClientService.