Reputation: 1680
I want to create a custom implementation of Spring Authorization Server with 2 custom federation providers:
AuthorizationGrantType.AUTHORIZATION_CODE
for React web clients with login page. Used to get users from Keycloak.AuthorizationGrantType.CLIENT_CREDENTIALS
. User credentials will fe fetched again from a second Cognito provider using API call.Spring Security configuration:
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.tokenEndpoint(tokenEndpoint ->
{
tokenEndpoint.accessTokenRequestConverter(new ThirdPartyPreAuthenticationConverter());
tokenEndpoint.authenticationProvider(
new ApikeyAuthenticationProvider(
jwtGenerator(jwkSource()),
oAuth2BasicTokenClaimsProvider,
apikeyRegisteredClientRepository(passwordEncoder()),
passwordEncoder(),
tokenGenerator));
tokenEndpoint.authenticationProvider(
new UsersAuthenticationProvider(
jwtGenerator(jwkSource()),
pegasusClient,
usersRegisteredClientRepository(passwordEncoder()),
passwordEncoder(),
tokenGenerator));
tokenEndpoint.accessTokenResponseHandler(new DispatchGeneratedTokenHandler());
});
http.oauth2ResourceServer(oauth2ResourceServer ->
oauth2ResourceServer.jwt(Customizer.withDefaults())
);
// @formatter:on
return http.build();
}
@Bean
public InMemoryRegisteredClientRepository apikeyRegisteredClientRepository(PasswordEncoder passwordEncoder) {
RegisteredClient tokenExchangeClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId(clientId)
.clientSecret(passwordEncoder.encode(clientSecret))
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.scope("internal.read")
.scope("internal.write")
.build();
InMemoryRegisteredClientRepository registeredClientRepository = new InMemoryRegisteredClientRepository(tokenExchangeClient);
registeredClientRepository.save(tokenExchangeClient);
return registeredClientRepository;
}
@Bean
@Primary
public InMemoryRegisteredClientRepository usersRegisteredClientRepository(PasswordEncoder passwordEncoder) {
RegisteredClient tokenExchangeClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId(clientId)
.clientSecret(passwordEncoder.encode(clientSecret))
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.scope("internal.read")
.scope("internal.write")
.redirectUri("http://localhost:8080/login/oauth2/code/my-client") // Add at least one redirect URI
.redirectUri("http://localhost:8080/authorized") // Another example URI
.build();
InMemoryRegisteredClientRepository registeredClientRepository = new InMemoryRegisteredClientRepository(tokenExchangeClient);
registeredClientRepository.save(tokenExchangeClient);
return registeredClientRepository;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@SneakyThrows
@Bean
public JWKSource<SecurityContext> jwkSource() {
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 (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
}
@Bean
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
}
@Bean
public JwtEncoder jwtGenerator(JWKSource<SecurityContext> jwkSource) {
return new NimbusJwtEncoder(jwkSource);
}
@Bean
public AuthorizationServerSettings authorizationServerSettings() {
return AuthorizationServerSettings.builder().build();
}
@Bean
public AuthenticationProvider apikeyAuthenticationProvider() {
return new ApikeyAuthenticationProvider(
jwtGenerator(jwkSource()),
oAuth2BasicTokenClaimsProvider,
apikeyRegisteredClientRepository(passwordEncoder()),
passwordEncoder(),
tokenGenerator);
}
@Bean
@Primary
public AuthenticationProvider usersAuthenticationProvider() {
return new UsersAuthenticationProvider(
jwtGenerator(jwkSource()), usersRegisteredClientRepository(passwordEncoder()),
passwordEncoder(),
tokenGenerator);
}
User Provider:
public class UsersAuthenticationProvider implements AuthenticationProvider {
....
public UsersAuthenticationProvider(...) {
.........
}
@Override
public OAuth2AccessTokenAuthenticationToken authenticate(Authentication authentication) throws AuthenticationException {
OAuth2ThirdPartyClientCredentialsAuthenticationToken token =
(OAuth2ThirdPartyClientCredentialsAuthenticationToken) authentication;
RegisteredClient registeredClient =
registeredClientRepository.findByClientId(token.getClientId());
if(registeredClient == null){
throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_CLIENT);
}
if(!encoder.matches(token.getClientSecret(), registeredClient.getClientSecret())) {
throw new OAuth2AuthenticationException(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT);
}
Map<String, Object> claims = null; // !!! TEMPORARY disabled
this.jwtEncoder.encode(JwtEncoderParameters.from(jwsHeaderBuilder.build(), claimsBuilder.build()));
OAuth2AccessToken jwt = tokenGenerator.generateToken(claims);
return new OAuth2AccessTokenAuthenticationToken(
registeredClient,
authentication,
jwt);
}
@Override
public boolean supports(Class<?> authentication) {
return OAuth2ThirdPartyClientCredentialsAuthenticationToken.class.isAssignableFrom(authentication);
}
}
Second provider:
@Primary
public class ApikeyAuthenticationProvider implements AuthenticationProvider {
...........
public ApikeyAuthenticationProvider(........) {
..........
}
@Override
public OAuth2AccessTokenAuthenticationToken authenticate(Authentication authentication) throws AuthenticationException {
OAuth2ThirdPartyClientCredentialsAuthenticationToken token =
(OAuth2ThirdPartyClientCredentialsAuthenticationToken) authentication;
RegisteredClient registeredClient =
registeredClientRepository.findByClientId(token.getClientId());
if(registeredClient == null){
throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_CLIENT);
}
if(!encoder.matches(token.getClientSecret(), registeredClient.getClientSecret())) {
throw new OAuth2AuthenticationException(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT);
}
Map<String, Object> claims =
apikeyAuthenticationProviderClient.getClaims(......);
OAuth2AccessToken jwt = tokenGenerator.generateToken(claims);
return new OAuth2AccessTokenAuthenticationToken(
registeredClient,
authentication,
jwt);
}
@Override
public boolean supports(Class<?> authentication) {
return OAuth2ThirdPartyClientCredentialsAuthenticationToken.class.isAssignableFrom(authentication);
}
}
This setup is not working. Do you know what is the proper way to implement it?
Upvotes: 2
Views: 232
Reputation: 875
The problem you are describing looks like an issue I run into, where 2 different security mechanisms could not be combined in a single Spring SecurityFilterChain
.
Under the assumption you run into that same issue, I am writing this answer.
SecurityFilterChain
are a collection of security policies and settings, and of you have contradicting requirements for each security mechanism / provider, you need to create multiple SecurityFilterChains which is documented here, but lacking good example code.
I recommend to create one SecurityFilterChain
for each security provider, roughly starting like this:
@Bean
@Order(10) // Higher priority for API
public SecurityFilterChain authorizationApiSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
// Apply security filter only for paths starting with /api/**
http.securityMatcher("/api/**");
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.tokenEndpoint(tokenEndpoint ->
{
tokenEndpoint.accessTokenRequestConverter(new ThirdPartyPreAuthenticationConverter());
tokenEndpoint.authenticationProvider(
new ApikeyAuthenticationProvider(
jwtGenerator(jwkSource()),
oAuth2BasicTokenClaimsProvider,
apikeyRegisteredClientRepository(passwordEncoder()),
passwordEncoder(),
tokenGenerator));
tokenEndpoint.accessTokenResponseHandler(new DispatchGeneratedTokenHandler());
});
http.oauth2ResourceServer(oauth2ResourceServer ->
oauth2ResourceServer.jwt(Customizer.withDefaults())
);
// @formatter:on
return http.build();
}
@Bean
@Order(20) // Default user security chain is only applied if API chain does not match
public SecurityFilterChain authorizationUserSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.tokenEndpoint(tokenEndpoint ->
{
tokenEndpoint.accessTokenRequestConverter(new ThirdPartyPreAuthenticationConverter());
tokenEndpoint.authenticationProvider(
new UsersAuthenticationProvider(
jwtGenerator(jwkSource()),
pegasusClient,
usersRegisteredClientRepository(passwordEncoder()),
passwordEncoder(),
tokenGenerator));
tokenEndpoint.accessTokenResponseHandler(new DispatchGeneratedTokenHandler());
});
http.oauth2ResourceServer(oauth2ResourceServer ->
oauth2ResourceServer.jwt(Customizer.withDefaults())
);
// @formatter:on
return http.build();
}
As I run in the same trouble, I give you a copy of my code, as an example. In this scenario I had to separate SOAP CXF API traffic, from user authentication. We also use Keycloak for user authentication. The SOAP CXF has a proprietary security mechanism.
/**
* SOAP endpoints (stateless) filter chain
*/
@Bean
@Order(10) // SecurityFilterChain filter sequence
public SecurityFilterChain soapSecurityFilterChain(HttpSecurity http) throws Exception {
http.securityMatcher(req -> {
// Only applies to request routed to the Apache CXF Servlet, handling SOAP Web Services
HttpServletMapping httpServletMapping = req.getHttpServletMapping();
return httpServletMapping != null
&& "cxfServletRegistration".equals(httpServletMapping.getServletName());
}).authorizeHttpRequests(authorize ->
// Allow all SOAP service requests
authorize.anyRequest().permitAll());
// Stateless for SOAP, disable session management
http.sessionManagement(
session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
// Disable CSRF
http.csrf(AbstractHttpConfigurer::disable);
return http.build();
}
@Bean
@Order(20)
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(authorize -> authorize
// These are public
.requestMatchers(AdminResource.ADMIN_DICTIONARY_RESOURCE_PATH, HomeResource.LOGOUT_PATH,
AssetConfig.ASSETS_PATH, AssetConfig.IMAGERY_PATH)
.permitAll()
// Block the default GraphQL endpoint
.requestMatchers(unusedGraphQLEndpoint).denyAll()
// The rest requires authentication
.anyRequest().authenticated()) // -
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.oauth2Login(Customizer.withDefaults());
if (redirectToHttps) {
http.requiresChannel(channel -> channel.anyRequest().requiresSecure());
}
return http.build();
}
Note that only one SecurityChain shall be applied. The http.securityMatcher
determines which SecurityChain should used. It first follows the load order, controlled by @Orderannotation, and the first SecurityChain which matched the
http.securityMatcher` is applied.
Please also note we added additional properties to disable CSRF and make the session management stateless, for the API related SecurityChain.
Upvotes: 0
Reputation: 1046
Adding my implementation on top of baeldung's spring-auth-server guide by extending the authorization to support spring extension grant type
Overall authorization that wraps User's OIDC flow and Device's access token flow are as illustrated:
That analogous to your requirement:
1.A & B: When user tries to access the 127.0.0.1:8080/articles endpoint, it will redirect them to login endpoint where they has to be authenticated themselves by providing their username n password
1.E After successful authentication, user get the resource data representation as json via web client
2.A Device has to obtain the access token via custom grant type with basic authorization as client id n secret
2.C Device has now to approach the articles endpoint with the obtained bearer token to get the resource data
I'm hoping that these will give some path to proceed you to the next level. Added the complete code commit in GitHub and sequence flow in Medium for any further reference.
Upvotes: 3