Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

support password grant #343

Closed
wants to merge 2 commits into from
Closed

Conversation

Leegecho
Copy link

@Leegecho Leegecho commented Jul 7, 2021

support password grant

@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged label Jul 7, 2021
@sjohnr
Copy link
Member

sjohnr commented Jul 7, 2021

@Leegecho, as stated in this comment, we will not be providing support for the password grant as it is deprecated in OAuth 2.1. For reference, see OAuth 2.1 and It's Time for OAuth 2.1.

If you are interested, I would encourage you to work up a sample project that supports the password grant type on top of this project. See the thread on gh-139 regarding customization options, and add a note for any missing areas where the framework makes it difficult to implement this customization outside the framework. This will help us identify areas for improvement so that deprecated or custom grant types can still be supported by applications that need them.

Going forward, please reach out first before any work is done on a new feature to make sure it will get accepted as we don't want you (or anyone) to spend unnecessary time on it. It's always best to open an issue for a new feature enhancement to discuss it first before any work is started. Thanks for your understanding.

@sjohnr sjohnr closed this Jul 7, 2021
@sjohnr sjohnr self-assigned this Jul 7, 2021
@sjohnr sjohnr added status: declined A suggestion or change that we don't feel we should currently apply and removed status: waiting-for-triage An issue we've not yet triaged labels Jul 7, 2021
@Basit-Mahmood
Copy link

ResourceOwnerPasswordType.zip

Hi, Sorry If I am not allowed to post comment here. But for custom type like the password. You can easily implement it. Actually it would be easy if there is an option to add AuthenticationConverter in the existing list of Authentication Converter of class OAuth2TokenEndpointFilter. Here is the code. Basically it is a constructor of OAuth2TokenEndpointfilter

`public OAuth2TokenEndpointFilter(AuthenticationManager authenticationManager, String tokenEndpointUri) {
     .....
     this.authenticationConverter = new DelegatingAuthenticationConverter(
			Arrays.asList(
					new OAuth2AuthorizationCodeAuthenticationConverter(),
					new OAuth2RefreshTokenAuthenticationConverter(),
					new OAuth2ClientCredentialsAuthenticationConverter()));
    }
  }`

Right now we cannot add any AuthenticationConverter in the existing list. Like if there is a custom converter for example ResourceOwnerPasswordAuthenticationConverter. We can not add it in the ArrayList. When request will come for password grant type then in method doFilterInternal(). At line

`Authentication authorizationGrantAuthentication = this.authenticationConverter.convert(request);
if (authorizationGrantAuthentication == null) {
    throwError(OAuth2ErrorCodes.UNSUPPORTED_GRANT_TYPE, OAuth2ParameterNames.GRANT_TYPE);
}` 

There will be an error. Becasue there is no converter that can handle passwrod grant type. Anyways what I did. I made a copy of the existing OAuth2TokenEndpointFilter. Named it CustomOAuth2TokenEndpointFilter. It is the same class as OAuth2TokenEndpointFilter but in the constructor I added the ResourceOwnerPasswordAuthenticationConverter. Here is the constructor

` public class CustomOAuth2TokenEndpointFilter extends OncePerRequestFilter {
      .....
      public CustomOAuth2TokenEndpointFilter(AuthenticationManager authenticationManager, String tokenEndpointUri) {
	......
	this.tokenEndpointMatcher = new AntPathRequestMatcher(tokenEndpointUri, HttpMethod.POST.name());
	List<AuthenticationConverter> converters = new ArrayList<>();
	converters.add(new AuthorizationCodeAuthenticationConverter());
	converters.add(new RefreshTokenAuthenticationConverter());
	converters.add(new ClientCredentialsAuthenticationConverter());
	converters.add(new ResourceOwnerPasswordAuthenticationConverter());
	this.authorizationGrantAuthenticationConverter = new DelegatingAuthenticationConverter(converters);
    }
        ......

    private class ResourceOwnerPasswordAuthenticationConverter implements AuthenticationConverter {
        @Override
        public Authentication convert(HttpServletRequest request) {
            
            // grant_type (REQUIRED)
	    String grantType = request.getParameter(OAuth2ParameterNames.GRANT_TYPE);
	    if (!AuthorizationGrantType.PASSWORD.getValue().equals(grantType)) {
		    return null;
	    }

           /// do parameter validations just like other converters then
           clientPrincipal = SecurityContextHolder.getContext().getAuthentication();
           if (clientPrincipal == null) {
               throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.USERNAME);
           }
		
          try {
            UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(username, password);
            userPrincipal = authenticationManager.authenticate(usernamePasswordAuthenticationToken);
            } catch (Exception ex) {
                String errorMessage = String.format("Invalid username: %s or password", OAuth2ParameterNames.USERNAME);
	        throwError(OAuth2ErrorCodes.INVALID_REQUEST, errorMessage);
            }
		
            Map<String, Object> additionalParameters = parameters
                .entrySet()
	        .stream()
	        .filter(e -> !e.getKey().equals(OAuth2ParameterNames.GRANT_TYPE) &&
						!e.getKey().equals(OAuth2ParameterNames.SCOPE))
	        .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().get(0)));
		
	        return new ResourceOwnerPasswordAuthenticationToken(AuthorizationGrantType.PASSWORD, clientPrincipal, userPrincipal, additionalParameters);  
            }
        }
 }

`

Here is the ResourceOwnerPasswordAuthenticationToken class. I just saw how other classes did it like the existing OAuth2ClientCredentialsAuthenticationToken

`public class ResourceOwnerPasswordAuthenticationToken extends AbstractAuthenticationToken {
     
    private final AuthorizationGrantType authorizationGrantType;
    private final Authentication clientPrincipal;
    private final Authentication userPrincipal;
    private final Map<String, Object> additionalParameters;

    public ResourceOwnerPasswordAuthenticationToken(AuthorizationGrantType authorizationGrantType,
		Authentication clientPrincipal, Authentication userPrincipal, @Nullable Map<String, Object> additionalParameters) {
	super(Collections.emptyList());
	Assert.notNull(authorizationGrantType, "authorizationGrantType cannot be null");
	Assert.notNull(clientPrincipal, "clientPrincipal cannot be null");
	Assert.notNull(clientPrincipal, "userPrincipal cannot be null");
	this.authorizationGrantType = authorizationGrantType;
	this.clientPrincipal = clientPrincipal;
	this.userPrincipal = userPrincipal;
	this.additionalParameters = Collections.unmodifiableMap(additionalParameters != null ? new HashMap<>(additionalParameters) : Collections.emptyMap());
    }
    // getters
}`

Similarly you can implement the CustomOAuth2ResourceOwnerPasswordAuthenticationProvider

`public class CustomOAuth2ResourceOwnerPasswordAuthenticationProvider implements AuthenticationProvider {
    private final OAuth2AuthorizationService authorizationService;
    private final JwtEncoder jwtEncoder;
    private OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer = (context) -> {};
    private ProviderSettings providerSettings;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {

        ResourceOwnerPasswordAuthenticationToken resouceOwnerPasswordAuthentication = (ResourceOwnerPasswordAuthenticationToken) authentication;

	OAuth2ClientAuthenticationToken clientPrincipal = getAuthenticatedClientElseThrowInvalidClient(resouceOwnerPasswordAuthentication);
	UsernamePasswordAuthenticationToken userPrincipal = getAuthenticatedUsertElseThrowInvalidUser(resouceOwnerPasswordAuthentication);
        // do other stuff. See the implementations like OAuth2AuthorizationCodeAuthenticationProvider and OAuth2ClientCredentialsAuthenticationProvider
   }

    private UsernamePasswordAuthenticationToken getAuthenticatedUsertElseThrowInvalidUser(Authentication authentication) {
	
	UsernamePasswordAuthenticationToken userPrincipal = null;
	
	if (UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication.getPrincipal().getClass())) {
		userPrincipal = (UsernamePasswordAuthenticationToken) authentication.getPrincipal();
	}
	
	if (userPrincipal != null && userPrincipal.isAuthenticated()) {
		return userPrincipal;
	}
	
	String errorMessage = String.format("Invalid username: %s or password", OAuth2ParameterNames.USERNAME);
	throw new OAuth2AuthenticationException(new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST, errorMessage, ""));
    }
}`

Finally here is the configuration. In the configuration I am changing the filter position. Ofcourse which is not good. You can change the token end point in the Provider setting so CustomoAuth2TokenEndpointFilter will call.

`@Configuration(proxyBeanMethods = false)
 public class AuthorizationServerConfiguration {
 
    @Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
    
        ....
        SecurityFilterChain securityFilterChain = http.formLogin(Customizer.withDefaults()).build();
	
	/**
	 * Custom configuration for Resource Owner Password grant type and custom oauth2 token. Current implementation has no
	 * support for Resource Owner Password grant type
	 */
	AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);
	JwtEncoder jwtEncoder = http.getSharedObject(JwtEncoder.class);
	ProviderSettings providerSettings = http.getSharedObject(ProviderSettings.class);
	OAuth2AuthorizationService authorizationService = http.getSharedObject(OAuth2AuthorizationService.class);
	OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer = buildCustomizer();
	
	CustomOAuth2ResourceOwnerPasswordAuthenticationProvider resourceOwnerPasswordAuthenticationProvider =
			new CustomOAuth2ResourceOwnerPasswordAuthenticationProvider(authorizationService, jwtEncoder);
	if (jwtCustomizer != null) {
		resourceOwnerPasswordAuthenticationProvider.setJwtCustomizer(jwtCustomizer);
	}
	
	resourceOwnerPasswordAuthenticationProvider.setProviderSettings(providerSettings);
	
	CustomOAuth2TokenEndpointFilter customTokenEndpointFilter = new CustomOAuth2TokenEndpointFilter(authenticationManager, providerSettings.tokenEndpoint());
	
	// This will add new authentication provider in the list of existing authentication providers.
	http.authenticationProvider(resourceOwnerPasswordAuthenticationProvider);
	http.addFilterBefore(customTokenEndpointFilter, OAuth2TokenEndpointFilter.class); 
	
	securityFilterChain = http.getOrBuild();
	
	securityFilterChain.getFilters().add(19, customTokenEndpointFilter);
	
	return securityFilterChain;
    }

}`

Similarly we can add any custom type if there is an option to add converter in the existing List of converters of OAuth2TokenEndpointfilter. Like after calling SecurityFilterChain securityFilterChain = http.formLogin(Customizer.withDefaults()).build(); we can get the OAuth2TokenEndpointFilter from the http.getSharedObject(OAuth2TokenEndpointFilter.class). Get the List from this class and just add the custom converter in the list.

As ClientSecretPostAuthenticationConverter already there so no need to implement any converter. But I think if this option (Option to add AuthenticationConverter like ClientSecretPostAuthenticationConverter) also available for class OAuth2ClientAuthenticationFilter. Here is the construcor

`public class OAuth2ClientAuthenticationFilter extends OncePerRequestFilter {
     ....
     public OAuth2ClientAuthenticationFilter(AuthenticationManager authenticationManager,
		RequestMatcher requestMatcher) {
	........
	this.authenticationConverter = new DelegatingAuthenticationConverter(
			Arrays.asList(
					new ClientSecretBasicAuthenticationConverter(),
					new ClientSecretPostAuthenticationConverter(),
					new PublicClientAuthenticationConverter()));
	this.authenticationSuccessHandler = this::onAuthenticationSuccess;
	this.authenticationFailureHandler = this::onAuthenticationFailure;
}
}`

Then anyone can add any custom type for authentication. password grant type or any other grant type.

OAuth2 Password Client.zip

Hope it will help.

@jgrandja
Copy link
Collaborator

jgrandja commented Aug 11, 2021

Thanks @Basit-Mahmood !

Actually it would be easy if there is an option to add AuthenticationConverter in the existing list of Authentication Converter of class OAuth2TokenEndpointFilter

This is now possible via gh-319 and the associated commits. See example configuration.

@Basit-Mahmood
Copy link

@jgrandja Thanks for your response. But I didn't mean to set the custom token end point. I was saying about adding converters in the existing converters. So using the existing token end point (oauth2/token). But adding the functionality. For example consider this configuration

`@Configuration(proxyBeanMethods = false)
 public class AuthorizationServerConfiguration {
     
     @Bean
     @Order(Ordered.HIGHEST_PRECEDENCE)
     public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {

         OAuth2AuthorizationServerConfigurer<HttpSecurity> authorizationServerConfigurer = new OAuth2AuthorizationServerConfigurer<>();
	
	authorizationServerConfigurer.authorizationEndpoint(authorizationEndpoint ->
		authorizationEndpoint.consentPage("/oauth2/consent"));
	
	// AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class); can't use it here. null here
	
	RequestMatcher endpointsMatcher = authorizationServerConfigurer.getEndpointsMatcher();
	
	http
		.requestMatcher(endpointsMatcher)
		.authorizeRequests(authorizeRequests -> authorizeRequests.anyRequest().authenticated())
		.csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher))
		.apply(authorizationServerConfigurer);
	
	SecurityFilterChain securityFilterChain = http.formLogin(Customizer.withDefaults()).build();
	
	/**
	 * Custom configuration for Resource Owner Password grant type and custom oauth2 token. Current implementation has no
	 * support for Resource Owner Password grant type
	 */
	AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);
	JwtEncoder jwtEncoder = http.getSharedObject(JwtEncoder.class);
	ProviderSettings providerSettings = http.getSharedObject(ProviderSettings.class);
	OAuth2AuthorizationService authorizationService = http.getSharedObject(OAuth2AuthorizationService.class);
	OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer = buildCustomizer();
	
	CustomOAuth2ResourceOwnerPasswordAuthenticationProvider resourceOwnerPasswordAuthenticationProvider =
			new CustomOAuth2ResourceOwnerPasswordAuthenticationProvider(authorizationService, jwtEncoder);
	if (jwtCustomizer != null) {
		resourceOwnerPasswordAuthenticationProvider.setJwtCustomizer(jwtCustomizer);
	}
	
	resourceOwnerPasswordAuthenticationProvider.setProviderSettings(providerSettings);
	
	CustomOAuth2TokenEndpointFilter customTokenEndpointFilter = new CustomOAuth2TokenEndpointFilter(authenticationManager, providerSettings.tokenEndpoint());
	
	// This will add new authentication provider in the list of existing authentication providers.
	http.authenticationProvider(resourceOwnerPasswordAuthenticationProvider);
	http.addFilterBefore(customTokenEndpointFilter, OAuth2TokenEndpointFilter.class); 
	
	securityFilterChain = http.getOrBuild();
	
	securityFilterChain.getFilters().add(19, customTokenEndpointFilter);
	
	return securityFilterChain;

     }
 }`

Note I cannot use AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class); before SecurityFilterChain securityFilterChain = http.formLogin(Customizer.withDefaults()).build(); Because I will get null. But after that I can get the shared objects from HttpSecurity http. Also consider the adding of

`// This will add new authentication provider in the list of existing authentication providers.
http.authenticationProvider(resourceOwnerPasswordAuthenticationProvider);`

It will add in the list of existing authentication providers.

Now consider OAuth2TokenEndpointFilter class.

`public class OAuth2TokenEndpointFilter extends OncePerRequestFilter {

    private AuthenticationConverter authenticationConverter;

     public OAuth2TokenEndpointFilter(AuthenticationManager authenticationManager, String tokenEndpointUri) {
	.....
	this.authenticationConverter = new DelegatingAuthenticationConverter(
			Arrays.asList(
					new OAuth2AuthorizationCodeAuthenticationConverter(),
					new OAuth2RefreshTokenAuthenticationConverter(),
					new OAuth2ClientCredentialsAuthenticationConverter()));
    }

     @Override
     protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

         try {
             .....
             Authentication authorizationGrantAuthentication = this.authenticationConverter.convert(request);
             if (authorizationGrantAuthentication == null) {
		    throwError(OAuth2ErrorCodes.UNSUPPORTED_GRANT_TYPE, OAuth2ParameterNames.GRANT_TYPE);
	    }
	    if (authorizationGrantAuthentication instanceof AbstractAuthenticationToken) {
		((AbstractAuthenticationToken) authorizationGrantAuthentication).setDetails(this.authenticationDetailsSource.buildDetails(request));
	    }

	    OAuth2AccessTokenAuthenticationToken accessTokenAuthentication =
				(OAuth2AccessTokenAuthenticationToken) this.authenticationManager.authenticate(authorizationGrantAuthentication);
	    this.authenticationSuccessHandler.onAuthenticationSuccess(request, response, accessTokenAuthentication);
         } catch (OAuth2AuthenticationException ex) {
             .....
         }

     }

 }`

Now the line OAuth2AccessTokenAuthenticationToken accessTokenAuthentication = (OAuth2AccessTokenAuthenticationToken) this.authenticationManager.authenticate(authorizationGrantAuthentication); will ultimately call CustomOAuth2ResourceOwnerPasswordAuthenticationProvider becasue I have added it in the existing providers list.

Similarly if we have something like http.authorizationGrantAuthenticationConverter(resourceOwnerPasswordAuthenticationConverter); Then it will add this converter in the existing list of converters. It will add the converter in the existing list. Like we already have three. But we are adding another one, custom in the existing list.

`this.authenticationConverter = new DelegatingAuthenticationConverter(
			Arrays.asList(
					new OAuth2AuthorizationCodeAuthenticationConverter(),
					new OAuth2RefreshTokenAuthenticationConverter(),
					new OAuth2ClientCredentialsAuthenticationConverter()));`

Now the line Authentication authorizationGrantAuthentication = this.authenticationConverter.convert(request); will call the custom converter base on the logic in the class.

I think it will simplify the configuration of adding custom grant type. SO instead of setting the whole Customizer<OAuth2TokenEndpointConfigurer> tokenEndpointCustomizer in configuration. One can simply add custom converters in the existing configuration.

I am not saying that no need of gh-319. It is great. It is giving us the flexibility to set the whole OAuth2TokenEndpointConfigurer.

I don't know if it's sound silly. Or whether it makes sense or not. But I think it is easy if I just want to add converter and want to use the existing endpoint.

Similar functionality can be added for class OAuth2ClientAuthenticationFilter.

Thanks

@sjohnr
Copy link
Member

sjohnr commented Aug 12, 2021

@Basit-Mahmood, the OAuth2TokenEndpointfilter has the ability to set a custom converter. Is that not the functionality you're looking for? I understand that convenience in the configurer is nice, but things like converters are probably a bit specific to be something common that everyone needs to configure in most applications, hence it's not currently an option in the configurer, but is available to customize directly on the class. I believe you can use an ObjectPostProcessor, something like:

OAuth2AuthorizationServerConfigurer<HttpSecurity> authorizationServerConfigurer =
		new OAuth2AuthorizationServerConfigurer<>();
http
	.apply(authorizationServerConfigurer
			.withObjectPostProcessor(new ObjectPostProcessor<OAuth2TokenEndpointFilter>() {
				@Override
				public <O extends OAuth2TokenEndpointFilter> O postProcess(O oauth2TokenEndpointFilter) {
					oauth2TokenEndpointFilter.setAuthenticationConverter(new DelegatingAuthenticationConverter(
							Arrays.asList(
									new OAuth2AuthorizationCodeAuthenticationConverter(),
									new OAuth2RefreshTokenAuthenticationConverter(),
									new OAuth2ClientCredentialsAuthenticationConverter(),
									new ResourceOwnerPasswordAuthenticationConverter())));
					return oauth2TokenEndpointFilter;
				}
			})
	);

Note: I have not tested this approach yet, so feedback welcome.

If this isn't what you're looking for, and other options aren't covered by gh-139, can you open a specific enhancement request for any specific item that is impossible to customize in the framework (e.g. not just not as convenient as it could be)? We're most interested at this stage in things that are not possible to customize, before things that are not convenient to customize.

@Basit-Mahmood
Copy link

@sjohnr Hi, Thanks. Your approach is working fine. I think it's ok now. This is what I wanted. I always thought about ObjectPostProcessor. But it's good to see ObjectPostProcessor finally in action. Thanks :)

@sjohnr
Copy link
Member

sjohnr commented Aug 16, 2021

@Basit-Mahmood, you're quite welcome. Thanks for the feedback!

@sjohnr
Copy link
Member

sjohnr commented Aug 18, 2021

@Basit-Mahmood, just an FYI that I actually missed where you can already set this converter via the configurer as @jgrandja mentioned, so slightly easier than using a post processor:

OAuth2AuthorizationServerConfigurer<HttpSecurity> authorizationServerConfigurer =
		new OAuth2AuthorizationServerConfigurer<>();
http.apply(authorizationServerConfigurer
			.tokenEndpoint((tokenEndpoint) -> tokenEndpoint.accessTokenRequestConverter(new DelegatingAuthenticationConverter(
					Arrays.asList(
							new OAuth2AuthorizationCodeAuthenticationConverter(),
							new OAuth2RefreshTokenAuthenticationConverter(),
							new OAuth2ClientCredentialsAuthenticationConverter(),
							new ResourceOwnerPasswordAuthenticationConverter())))));

Sorry about the confusion, I missed it because I was looking for a different method name in the configurer.

@Basit-Mahmood
Copy link

@sjohnr Hi, Yup it is working too. Now I am using it. I think it is more close to the configuration setting. But both are working fine. This one and post processor. Thank you so much :)

@Mersenne255
Copy link

Hi guys @Basit-Mahmood @sjohnr !
Thanks so much for writing all this info down, but I somehow always manage to get lost in the middle.
Would it be possible to recap steps needed to have that password grant functional? - what are new custom classes that need to be introduced and what config/filters need to be overwritten?

I believe it will be as much appreciated to me as to posterity :)

Thanks in advance!

@sjohnr
Copy link
Member

sjohnr commented Aug 23, 2021

Hey @Mersenne255. Since it isn't our plan to support this grant, I'll let other community members answer that question. Preferably, if @Basit-Mahmood or anyone else has a solution, please link to a github repo in your own space, and move the discussion over to that repo and collaborate on it. It would be a nice way to help out and grow the community.

@Basit-Mahmood
Copy link

Basit-Mahmood commented Aug 24, 2021

@sjohnr I was thinking to just attaching the files here. But ok I will create a repository on my account and post a sample project there for anyone who is interested in Password Grant type with the current version of Spring authorization server. But it would be good if some of the classes in authorization server make public. Right now they are package protected.

  1. The class org.springframework.security.oauth2.server.authorization.authentication.JwtUtils and its methods and class org.springframework.security.oauth2.server.authorization.web.OAuth2EndpointUtils and its methods are package protected. I don't know is there any specific reason for make it package protected. But if this class and methods are public then it would be very good. Because when I implemented the Password grant type I had to use it but it was package protected so I just make a copy of it and use it. If this class and methods are public then there is no need of creating a new class.

Thanks & Regards
Basit Mahmood Ahmed

@ghost ghost mentioned this pull request Aug 25, 2021
@Basit-Mahmood
Copy link

Basit-Mahmood commented Aug 25, 2021

@sjohnr @Mersenne255 I have added the link .You can go to my profile and check the projects in repository. Otherwise try this link https://github.com/Basit-Mahmood/spring-authorization-server-password-grant-type-support. If it won't open just copy paste the url in browser it will open.

@Mersenne255
Copy link

Thank you @Basit-Mahmood many times over!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
status: declined A suggestion or change that we don't feel we should currently apply
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

6 participants