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();
}
}
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;
}
}
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;
}
}
This is the final 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);
}
}
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();
}
}
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;
}
}
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:
Then, create a user:
use your own code_challenge, you will need a verifier.
Get the token:
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)
Hi thanks for blog, how should we register client repositories in oautn config in my opinion you forgot registering ClientService.