Skip to content

Missing support for private_key_jwt in ClientRegistrations #9780

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

Closed
ThomasKasene opened this issue May 19, 2021 · 17 comments · Fixed by #9933
Closed

Missing support for private_key_jwt in ClientRegistrations #9780

ThomasKasene opened this issue May 19, 2021 · 17 comments · Fixed by #9933
Assignees
Labels
in: oauth2 An issue in OAuth2 modules (oauth2-core, oauth2-client, oauth2-resource-server, oauth2-jose) status: backported An issue that has been backported to maintenance branches type: bug A general bug
Milestone

Comments

@ThomasKasene
Copy link
Contributor

Describe the bug
I'm trying to set up an OAuth2 client against https://ver2.maskinporten.no/, whose .well-known configuration endpoint looks like this:

{
  "issuer": "https://ver2.maskinporten.no/",
  "token_endpoint": "https://ver2.maskinporten.no/token",
  "jwks_uri": "https://ver2.maskinporten.no/jwk",
  "token_endpoint_auth_methods_supported": [ "private_key_jwt" ],
  "grant_types_supported": [ "urn:ietf:params:oauth:grant-type:jwt-bearer" ]
}

I figured I'd try to use the new support for private_key_jwt in Spring Security 5.5.0 (in combination with Spring Boot 2.4.5), but when I start the application I get an error:

Caused by: java.lang.IllegalArgumentException: Only ClientAuthenticationMethod.CLIENT_SECRET_BASIC, ClientAuthenticationMethod.CLIENT_SECRET_POST and ClientAuthenticationMethod.NONE are supported. The issuer "https://ver2.maskinporten.no/" returned a configuration of [private_key_jwt]
	at org.springframework.security.oauth2.client.registration.ClientRegistrations.getClientAuthenticationMethod(ClientRegistrations.java:280) ~[spring-security-oauth2-client-5.5.0.jar:5.5.0]
	at org.springframework.security.oauth2.client.registration.ClientRegistrations.withProviderConfiguration(ClientRegistrations.java:243) ~[spring-security-oauth2-client-5.5.0.jar:5.5.0]
	at org.springframework.security.oauth2.client.registration.ClientRegistrations.lambda$getRfc8414Builder$1(ClientRegistrations.java:190) ~[spring-security-oauth2-client-5.5.0.jar:5.5.0]
	at org.springframework.security.oauth2.client.registration.ClientRegistrations.getBuilder(ClientRegistrations.java:209) ~[spring-security-oauth2-client-5.5.0.jar:5.5.0]
	at org.springframework.security.oauth2.client.registration.ClientRegistrations.fromIssuerLocation(ClientRegistrations.java:145) ~[spring-security-oauth2-client-5.5.0.jar:5.5.0]
	at org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientPropertiesRegistrationAdapter.getBuilderFromIssuerIfPossible(OAuth2ClientPropertiesRegistrationAdapter.java:83) ~[spring-boot-autoconfigure-2.4.5.jar:2.4.5]
	at org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientPropertiesRegistrationAdapter.getClientRegistration(OAuth2ClientPropertiesRegistrationAdapter.java:59) ~[spring-boot-autoconfigure-2.4.5.jar:2.4.5]
	at org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientPropertiesRegistrationAdapter.lambda$getClientRegistrations$0(OAuth2ClientPropertiesRegistrationAdapter.java:53) ~[spring-boot-autoconfigure-2.4.5.jar:2.4.5]
	at java.base/java.util.HashMap.forEach(HashMap.java:1336) ~[na:na]
	at org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientPropertiesRegistrationAdapter.getClientRegistrations(OAuth2ClientPropertiesRegistrationAdapter.java:52) ~[spring-boot-autoconfigure-2.4.5.jar:2.4.5]
	at org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientRegistrationRepositoryConfiguration.clientRegistrationRepository(OAuth2ClientRegistrationRepositoryConfiguration.java:49) ~[spring-boot-autoconfigure-2.4.5.jar:2.4.5]
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na]
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:na]
	at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na]
	at java.base/java.lang.reflect.Method.invoke(Method.java:566) ~[na:na]
	at org.springframework.beans.factory.support.SimpleInstantiationStrategy.instantiate(SimpleInstantiationStrategy.java:154) ~[spring-beans-5.3.6.jar:5.3.6]
	... 83 common frames omitted

To Reproduce
I try the following configuration in my application.yml, just to get off the ground:

spring:
  security.oauth2.client:
    provider:
      maskinporten.issuer-uri: https://ver2.maskinporten.no/
    registration:
      maskinporten-private-key-jwt:
        client-id: 1337
        provider: maskinporten
        scope: test:test

Expected behavior
I expected this part of the client setup to work "out of the box", but there appears to be something missing from org.springframework.security.oauth2.client.registration.ClientRegistrations.getClientAuthenticationMethod, maybe? Unless I'm not supposed to have reached that code path at all, in which case I don't know what to expect.

Sample
I put together a small, reproducible sample which uses Maven, hopefully that's okay.

@ThomasKasene ThomasKasene added status: waiting-for-triage An issue we've not yet triaged type: bug A general bug labels May 19, 2021
@anoopgarlapati
Copy link

@ThomasKasene It seems you are missing client-authentication-method: private_key_jwt property. Can you please try adding this property to your sample and validate.
There are good set of instructions in the Spring Security reference guide for client authentication using private_key_jwt.

@ThomasKasene
Copy link
Contributor Author

ThomasKasene commented May 19, 2021

I already tried with both client-authentication-method: private_key_jwt and authorization-grant-type: urn:ietf:params:oauth:grant-type:jwt-bearer but the result is the same. This happens when the application is trying to parse the result from the .well-known configuration endpoint, and it's encountering some hardcoded limits, if I understand it correctly.

(By the way, isn't the client registration smart enough to auto-configure itself with the client-authentication-method and the authorization-grant-type it gets from the .well-known endpoint, just like it does with the issuer-uri and jwk-set-uri?)

@marcusdacoregio marcusdacoregio added in: oauth2 An issue in OAuth2 modules (oauth2-core, oauth2-client, oauth2-resource-server, oauth2-jose) and removed status: waiting-for-triage An issue we've not yet triaged labels May 21, 2021
@ThomasKasene ThomasKasene changed the title Missing support for private-key-jwt in ClientRegistrations Missing support for private_key_jwt in ClientRegistrations May 26, 2021
@jgrandja
Copy link
Contributor

Related gh-9795

@jgrandja jgrandja assigned sjohnr and unassigned jgrandja May 26, 2021
@jgrandja
Copy link
Contributor

Thanks for reporting this bug @ThomasKasene.

In the meantime, you could workaround this by not configuring the issuer-uri property and instead explicitly configuring all the required ClientRegistration properties, e.g. token-uri, etc.

Also, could you please log a separate ticket for the issue related to authorization-grant-type.

@ThomasKasene
Copy link
Contributor Author

One way to do that is to override the ClientRegistrationRepository bean definition that Spring Boot creates? Something along the lines of:

    @Bean
    InMemoryClientRegistrationRepository clientRegistrationRepository(OAuth2ClientProperties properties) {

        Collection<ClientRegistration> propertyBackedClientRegistrations = OAuth2ClientPropertiesRegistrationAdapter
                .getClientRegistrations(properties).values();

        ClientRegistration maskinportenClientRegistration = ClientRegistration
                .withRegistrationId("maskinporten-private-key-jwt")
                .clientAuthenticationMethod(ClientAuthenticationMethod.PRIVATE_KEY_JWT)
                .authorizationGrantType(AuthorizationGrantType.JWT_BEARER)
                .scope("test:test")
                .tokenUri("https://ver2.maskinporten.no/token")
                .clientId("1337")
                .clientName("Maskinporten")
                .build();

        List<ClientRegistration> registrations = new ArrayList<>(propertyBackedClientRegistrations.size() + 1);
        registrations.addAll(propertyBackedClientRegistrations);
        registrations.add(maskinportenClientRegistration);

        return new InMemoryClientRegistrationRepository(registrations);
    }

I'm not sure what you mean by "the issue related to authorization-grant-type" though? If there's more trouble ahead with the property/discovery URL-based configuration, I don't know about it yet, as I haven't gotten past this first issue.

@jgrandja
Copy link
Contributor

@ThomasKasene

One way to do that is to override the ClientRegistrationRepository bean definition

Yes that is one way, but the way I suggested is to simply define the Boot properties without defining issuer-uri. You would need to define the token-uri property within the provider properties.

I'm not sure what you mean by "the issue related to authorization-grant-type"

Regarding your comment:

I already tried with both client-authentication-method: private_key_jwt and authorization-grant-type: urn:ietf:params:oauth:grant-type:jwt-bearer but the result is the same.

Looks like only authorization_code is allowed else an error is thrown here as well.

@cortex93
Copy link

cortex93 commented May 26, 2021

(By the way, isn't the client registration smart enough to auto-configure itself with the client-authentication-method and the authorization-grant-type it gets from the .well-known endpoint, just like it does with the issuer-uri and jwk-set-uri?)

No, issuer metadata exposes what the authorization server support not what a client must use. Client will use the metadata depending on what grant type and client authentication method they want to use.

On the other hand, it would be possible to check upfront that the client configuration will eventually fail to request a token due to unsupported configuration but I'm not convince it worth it. I haven't seen any client implementation doing that kind of check. metadata is often only used to get endpoint uri.

I already tried with both client-authentication-method: private_key_jwt and authorization-grant-type: urn:ietf:params:oauth:grant-type:jwt-bearer but the result is the same.

Looks like only authorization_code is allowed else an error is thrown here as well.

It works for me with jwt-bearer grant type and a custom grant type. I use JWK keys.
Configuration may be simpler by avoiding to redefine each Token Response Client. Just declaring a JWK resolver should be sufficient to activate the feature for all grant type.

@ThomasKasene
Copy link
Contributor Author

ThomasKasene commented May 27, 2021

@jgrandja

Yes that is one way, but the way I suggested is to simply define the Boot properties without defining issuer-uri. You would need to define the token-uri property within the provider properties.

Of course, that is much easier!

Looks like only authorization_code is allowed else an error is thrown here as well.

Are you referring to this bit in ClientRegistrations.withProviderConfiguration?

if (grantTypes != null && !grantTypes.contains(GrantType.AUTHORIZATION_CODE)) {
	throw new IllegalArgumentException(
			"Only AuthorizationGrantType.AUTHORIZATION_CODE is supported. The issuer \"" + issuer
					+ "\" returned a configuration of " + grantTypes);
}

If so, I'll make sure to create a new issue for that! I just hadn't gotten to that part yet.

@cortex93

No, issuer metadata exposes what the authorization server support not what a client must use. Client will use the metadata depending on what grant type and client authentication method they want to use.

That is fair enough - an argument miiight be made for servers that only support one of each, but what, then, if they suddenly added support for one more?

Just declaring a JWK resolver should be sufficient to activate the feature for all grant type.

I might be going a little bit off-topic here, but do you have an example of how you do this? I don't find the documentation all that helpful as it doesn't give me any anchor points as to where to add the code. At the moment I'm trying to add my JWK resolver in a highly convoluted AuthorizedClientServiceOAuth2AuthorizedClientManager bean definition because I don't know any better.

@jgrandja
Copy link
Contributor

@ThomasKasene

Are you referring to this bit in ClientRegistrations.withProviderConfiguration? ...

Yes. If you could create a new issue for this that would be great.

@sclorng
Copy link

sclorng commented May 27, 2021

I might be going a little bit off-topic here, but do you have an example of how you do this? I don't find the documentation all that helpful as it doesn't give me any anchor points as to where to add the code. At the moment I'm trying to add my JWK resolver in a highly convoluted AuthorizedClientServiceOAuth2AuthorizedClientManager bean definition because I don't know any better.

This should get you start : https://gist.github.com/scrocquesel/d38549c64837f8eeae3d4619c850ba60

May I ask you what will be the flow of your token ? Specificaly, from where will you get the token you will pass to the maskinporten AS ?

@ThomasKasene
Copy link
Contributor Author

@scrocquesel

This should get you start : https://gist.github.com/scrocquesel/d38549c64837f8eeae3d4619c850ba60

Thanks a bunch for the sample. It looks as though I wasn't very far off myself, but I'm still not getting it to work.

May I ask you what will be the flow of your token ? Specificaly, from where will you get the token you will pass to the maskinporten AS ?

Certainly! For my particular case I'm hoping to do a machine-to-machine call, so no user involvement. This means that I don't have a tokenValue to inject into the Jwt instance, but that seems to be required by the Jwt constructor. And a Jwt is required by JwtBearerOAuth2AuthorizedClientProvider.authorize():

if (!(context.getPrincipal().getPrincipal() instanceof Jwt)) {
	return null;
}

The above check happens before the call to DefaultJwtBearerTokenResponseClient.getTokenResponse() which is where the JwtBearerGrantRequestEntityConverter/NimbusJwtClientAuthenticationParametersConverter get called, if I understand things correctly. But it's in NimbusJwtClientAuthenticationParametersConverter the JwtClaimsSet is being constructed and signed, so why am I even bothering with the Jwt instance? I smell a chicken-and-the-egg problem, or (perhaps more likely) that I'm misunderstanding something.

Just for some background, I'm trying to set up an interceptor for my RestTemplate:

public class MaskinportenAuthInterceptor implements ClientHttpRequestInterceptor {

    // ...

    @Override
    public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {

        ClientRegistration maskinporten = clientRegistrationRepository.findByRegistrationId("maskinporten-private-key-jwt");

        Jwt jwt = Jwt.withTokenValue("dummy")
                // ...
                // This is not really needed in the assertion to Maskinporten, but
                // JwtAuthenticationToken uses it to set its name-attribute.
                .subject("dummy-subject") 
                .build();

        // Not convinced JwtAuthenticationToken is the correct implementation to use here.
        JwtAuthenticationToken principal = new JwtAuthenticationToken(jwt);

        OAuth2AuthorizedClient client = authorizedServiceClientManager.authorize(OAuth2AuthorizeRequest
                .withClientRegistrationId(maskinporten.getRegistrationId())
                .principal(principal)
                .build());

        request.getHeaders().setBearerAuth(client.getAccessToken().getTokenValue());

        return execution.execute(request, body);
    }
}

Any pointers as to whether or not this is completely wrong are greatly appreciated!

@sclorng
Copy link

sclorng commented May 28, 2021

You are really close and you have the same issue as me. Currently, JwtBearer grant type don't allow to easily pass a custom Jwt. Which is the whole purpose of this grant type as per the rfc.

You may found some clue at the end of #9812

I ended up copy/pasting the JwtBearerOAuth2AuthorizedClientProvider as it is not really usefull in its current form to allow to pass a jwt converter. This allow to reuse the signing mechanism and only generate the Jwt when required which is not possible with the current implementation.
I update the gist so you can have a look at it.

@jgrandja
Copy link
Contributor

@ThomasKasene

I don't find the documentation all that helpful as it doesn't give me any anchor points as to where to add the code

Have you checked the reference documentation?

@jgrandja
Copy link
Contributor

jgrandja commented May 28, 2021

@ThomasKasene

For my particular case I'm hoping to do a machine-to-machine call, so no user involvement. This means that I don't have a tokenValue to inject into the Jwt instance, but that seems to be required by the Jwt constructor. And a Jwt is required by JwtBearerOAuth2AuthorizedClientProvider.authorize().

The JwtBearerOAuth2AuthorizedClientProvider implements the Authorization Grant type urn:ietf:params:oauth:grant-type:jwt-bearer.

See Section 2.1. Using JWTs as Authorization Grants:

To use a Bearer JWT as an authorization grant, the client uses an access token request...

Take note of the bold highlight...an access token request is sent to the Authorization Server's token endpoint to exchange the passed Jwt assertion for another Jwt assertion.

Since you are looking to perform machine-to-machine (service-to-service) call, then you need the capability to create a Jwt so you can pass on to the downstream service. Please add your requirements in gh-9208 and we'll see about getting the JwtEncoder PR merged for next release.

@jgrandja jgrandja added this to the 5.5.1 milestone May 28, 2021
@sclorng
Copy link

sclorng commented May 28, 2021

@ThomasKasene

For my particular case I'm hoping to do a machine-to-machine call, so no user involvement. This means that I don't have a tokenValue to inject into the Jwt instance, but that seems to be required by the Jwt constructor. And a Jwt is required by JwtBearerOAuth2AuthorizedClientProvider.authorize().

The JwtBearerOAuth2AuthorizedClientProvider implements the Authorization Grant type urn:ietf:params:oauth:grant-type:jwt-bearer.

See Section 2.1. Using JWTs as Authorization Grants:

To use a Bearer JWT as an authorization grant, the client uses an access token request...

Take note of the bold highlight...an access token request is sent to the Authorization Server's token endpoint to exchange the passed Jwt assertion for another Jwt assertion.

Since you are looking to perform machine-to-machine (service-to-service) call, then you need the capability to create a Jwt. Please add your requirements in gh-9208 and we'll see about getting the JwtEncoder PR merged for next release.

The spec says:

To use a Bearer JWT as an authorization grant, the client uses an
access token request as defined in Section 4 of the OAuth Assertion
Framework [RFC7521] with the following specific parameter values and
encodings.

I'm not an english expert, but this seems to means that the client should build a request to get an access token as defined in the section 4 of RFC7521 (grant_type, assertion, scope), not that the assertion of the access token request is actually an access token.

@ThomasKasene For a machine-to-machine call with no user involved you can use the client_id as the sub claim of the Jwt you will build. This should be equivalent to a client credentials grant flow.
See https://datatracker.ietf.org/doc/html/rfc7521#section-5.2

The assertion MUST contain a Subject. The Subject typically
identifies an authorized accessor for which the access token is
being requested (i.e., the resource owner or an authorized
delegate) but, in some cases, may be a pseudonymous identifier or
other value denoting an anonymous user. When the client is acting
on behalf of itself, the Subject MUST be the value of the client's
"client_id".

This is often used without the signed client_assertion authentication as they are very similar but it depends of how the AS is actually doing the check. The AS I use require the client to authenticate with a jwt bearer for every grant flow.

To ease that, you may actually use a class ClientActingOnBehalfOfItSelf implements Authentication and in my OAuth2AuthorizationContextJwtBearerConverter , test for the authorizationContext.getPrincipal() type, if it is this class then I the sub claim is set to clientRegistration.getClientId() otherwise authorizationContext.getPrincipal().getName().

String body = webClient    
  .get()    
  .attributes(authentication(new ClientActingOnBehalfOfItSelf ()).andThen(clientRegistrationId("test")))   
  .retrieve()   
  .bodyToMono(String.class)    
  .block();

This allow to use a default client registration without the client code to know the actual client id to use.

@sjohnr sjohnr added this to the 5.6.0-M1 milestone Jun 16, 2021
sjohnr pushed a commit to sjohnr/spring-security that referenced this issue Jun 16, 2021
sjohnr pushed a commit that referenced this issue Jun 16, 2021
@spring-projects-issues spring-projects-issues added status: backported An issue that has been backported to maintenance branches and removed for: backport-to-5.5.x labels Jun 16, 2021
sjohnr pushed a commit that referenced this issue Jun 16, 2021
akohli96 pushed a commit to akohli96/spring-security that referenced this issue Aug 25, 2021
@erlendfg
Copy link

erlendfg commented Oct 7, 2021

@ThomasKasene, did you manage to use private_key_jwt instead of clientSecret for ID-porten as well (not just Maskinporten)? I'm getting "Client authentication failed. No client authentication included.".

I have tried to follow these instructions:
https://docs.spring.io/spring-security/site/docs/current/reference/html5/#oauth2Client-jwt-bearer-auth

The accessTokenResponseClient is called, but not jwkResolver.

I'm sent to the auth provider, but get the error when I'm sent back to the application. Tried to configure the following in my OpenIdConnectConfig:

ClientRegistrations
.fromIssuerLocation(idPortenEndpoint)
.clientId(idPortenClientId)
.registrationId(IDPORTEN)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri(getPreEstablishedRedirectUriByClientName(IDPORTEN))
.scope(Arrays.asList(ID_PORTEN_SCOPES))
.userNameAttributeName(IdTokenClaimNames.SUB)
.clientName(IDPORTEN)
.clientAuthenticationMethod(ClientAuthenticationMethod.PRIVATE_KEY_JWT)
.build();

@ThomasKasene
Copy link
Contributor Author

@erlendfg alas, no - I haven't attempted to use ID Porten myself yet.

I know you didn't ask, but for anybody else who might be in the same pickle: As for Maskinporten, I gave up on using Spring Security's support for it. I realized that I would have to customize so much of Spring Security, make so many subclasses and implementations of stuff™, that in the end it just wasn't worth it. This is in part due to Spring Security's rigidity/commitment to follow the specs only, and partly because Maskinporten doesn't follow said specs precisely, meaning that shoehorning my Maskinporten-integration into Spring Security proved to be a difficult task.

Instead, I implemented a single @Service with its own RestTemplate and with logic and caching behavior that is pretty transparent to the reader, and then used it inside a ClientHttpRequestInterceptor implementation ...:

@RequiredArgsConstructor
public class MyResourceServiceAuthenticationInterceptor implements ClientHttpRequestInterceptor {

    private final MaskinportenService maskinportenService;

    @Override
    public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {

        request.getHeaders().setBearerAuth(maskinportenService.fetchAccessToken());

        return execution.execute(request, body);
    }
}

... which I added to the RestTemplate I used to call the target resource. Something along the lines of:

public MyResourceServiceConstructor(RestTemplateBuilder restTemplateBuilder,
                      MyResourceServiceAuthenticationInterceptor authenticationInterceptor) {

    this.restTemplate = restTemplateBuilder
            .interceptors(authenticationInterceptor)
            .build();
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
in: oauth2 An issue in OAuth2 modules (oauth2-core, oauth2-client, oauth2-resource-server, oauth2-jose) status: backported An issue that has been backported to maintenance branches type: bug A general bug
Projects
None yet
Development

Successfully merging a pull request may close this issue.

9 participants