DuendeSoftware / Support

Support for Duende Software products
20 stars 0 forks source link

Private Key JWT validation logic #823

Closed b3n3d17 closed 11 months ago

b3n3d17 commented 1 year ago

Which version of Duende IdentityServer are you using? v6

Which version of .NET are you using? dotnet --version 7.0.400

Describe the bug

The private key JWT validation logic rejects my authentication request. Here the 2 screenshot of the relevant error messages:

invalid_issuer jti_is_missing

To Reproduce

  1. create a JWT where sub and issuer are not equal.
  2. do not a JTI to your JWT
  3. try to use the token endpoint.

Expected behavior

I expect the validation logic to accept my Token even though subject and issuer are not equal and i do not have a JTI.

Additional context

Hi, i am trying to use k8s serviceaccount token projection to authenticate my microservice.

However, the provided Token Validation logic is stricter than required by [RFC7523 - Section 3](https://datatracker.ietf.org/doc/html/rfc7523#secti

on-3). Therefore, the authentication fails.

Dudende Identiy Server v6 checks for a JTI and it enforces the issuer to be the same as the client_id. Both checks are not required by the RFC (as far as I can read at least). I would like to have a "setting" which would facilitate only the minimum required checks. Currently I do not see any obvious way to change that behavior. Am I missing something obvious here?

I am using the following configuration:

builder.Services.AddIdentityServer(options =>
            {
                // https://docs.duendesoftware.com/identityserver/v6/fundamentals/resources/api_scopes#authorization-based-on-scopes
                options.EmitStaticAudienceClaim = true;
            })
            .AddInMemoryIdentityResources(Config.IdentityResources)
            .AddInMemoryApiScopes(Config.ApiScopes)
            .AddJwtBearerClientAuthentication()
            .AddInMemoryClients(Config.Clients);

        return builder.Build();
    }

And the following client configuration.

new Client
                      {
                          ClientId = "system:serviceaccount:test:default",
                          ClientSecrets =
                          {
                              new Secret
                              {
                                  Type = IdentityServerConstants.SecretTypes.JsonWebKey,
                                  //I have actual values here
                                  Value = "{'e':'AQAB','kid':'blablub','kty':'RSA','n':'bliblablub'}"
                              }
                          },
                          AllowedGrantTypes = GrantTypes.ClientCredentials,
                          AllowedScopes = { "api1" }
                      }        

Here a token which I manipulated to be accepted

{
  "aud": [
    "https://localhost:5001/connect/token"
  ],
  "exp": 1691752299,
  "iat": 1691745099,
  "iss": "system:serviceaccount:test:default",
  "kubernetes.io": {
    "namespace": "test",
    "pod": {
      "name": "simpleapp-5d7dcf96df-n7csk",
      "uid": "9fc443d7-5c7a-48d5-9679-0ee03b17d4c5"
    },
    "serviceaccount": {
      "name": "default",
      "uid": "0bea3006-fb60-49a3-bc80-7e6884d378ae"
    }
  },
  "nbf": 1691745099,
  "sub": "system:serviceaccount:test:default",
  "jti": "are you happy now"
}

Here the token which k8s actually provides me

{
  "aud": [
    "https://localhost:5001/connect/token"
  ],
  "exp": 1691752299,
  "iat": 1691745099,
  "iss": "http://some.randome.issuer.name.which.is.static.and.therefore.not.configurable",
  "kubernetes.io": {
    "namespace": "test",
    "pod": {
      "name": "simpleapp-5d7dcf96df-n7csk",
      "uid": "9fc443d7-5c7a-48d5-9679-0ee03b17d4c5"
    },
    "serviceaccount": {
      "name": "default",
      "uid": "0bea3006-fb60-49a3-bc80-7e6884d378ae"
    }
  },
  "nbf": 1691745099,
  "sub": "system:serviceaccount:test:default",
}

Thank you for your help

josephdecock commented 1 year ago

IdentityServer enforces that the subject, issuer, and client_id all match, because the semantics of our secret validation is that we want the client application to prove that it possesses a cryptographic secret. We do that by having the client sign a token with a private key and validate it using its public key. We don't have a mechanism for establishing trust with a third party that might issue tokens on behalf of the client. This does go beyond what is explicitly required by the specification, but the spec also says that implementations are allowed to add their own rules:

Application of additional restrictions and policy are at the discretion of the authorization server.

We require the jti claim because we use it to perform replay detection. While the spec says that tokens "MAY" include jti (MAY making it optional, so that spec-compliant implementations could accept a token without one), it also says that authorization servers may use the jti to do replay detection, as we do. Since we've decided to do so, we add the restriction that the jti must be present.

If you want different behavior, you can implement your own ISecretValidator. All of the behavior that I'm describing above is implemented in the PrivateKeyJwtSecretValidator (which implements that interface). You could create your own implementation of the ISecretValidator that didn't do replay detection and so didn't require the jti and that trusted jwts issued by some other issuer. Then just register the validator with DI. The way that the validation works, if any validator is able to validate, then the secret is trusted, so the existing validator not being able to validate shouldn't be an issue. I suppose it might waste resources, so if you wanted to you could avoid registering the validator in the first place. It gets registered by the AddJwtBearerClientAuthentication. Under the covers, that method is just registering a few things in DI. You could do that yourself, but leave out the validator you don't want:

// This is the IdentityServer method
    public static IIdentityServerBuilder AddJwtBearerClientAuthentication(this IIdentityServerBuilder builder)
    {
        builder.AddSecretParser<JwtBearerClientAssertionSecretParser>();
        builder.AddSecretValidator<PrivateKeyJwtSecretValidator>();

        return builder;
    }

// So do this instead of a call to AddJwtBearerClientAuthentication
builder.AddSecretParser<JwtBearerClientAssertionSecretParser>();
builder.AddSecretValidator<YourSecretValidator>(); // TODO, create your secret validator class
josephdecock commented 1 year ago

Also, the OIDC spec mandates our behavior:

private_key_jwt Clients that have registered a public key sign a JWT using that key. The Client authenticates in accordance with JSON Web Token (JWT) Profile for OAuth 2.0 Client Authentication and Authorization Grants [OAuth.JWT] and Assertion Framework for OAuth 2.0 Client Authentication and Authorization Grants [OAuth.Assertions]. The JWT MUST contain the following REQUIRED Claim Values and MAY contain the following OPTIONAL Claim Values:

    iss
        REQUIRED. Issuer. This MUST contain the client_id of the OAuth Client. 
    sub
        REQUIRED. Subject. This MUST contain the client_id of the OAuth Client.
b3n3d17 commented 1 year ago

Hi Joseph,

Thank you for the detailed descriptions. Also thank you for the hint, that the oidc spec has different required fields than the RFC I was looking at. Turns out my idea is not OIDC compliant after all ... ;(. I also did not realize that it was "that easy" to customize the validation behaviour, thanks for the hints.

Greetings Benedikt

hannestschofenig commented 11 months ago

Hi Joe,

you have implemented both OpenID Connect and OAuth specifications. However, you are enforcing the OpenID Connect specification processing rules by default and you require the implementation of a custom validator to get ordinary OAuth 2.0 handling.

In this specific case, the jti is an optional claim in the RFC 7523 specification and the issue does not need to be populated with the client_id.

I am wondering whether you have thought about configuring the resource server (relying party) such that a developer can conveniently switch between the OAuth specification behavior and the Open ID Connect behavior.

josephdecock commented 11 months ago

This isn't something we've got on our backlog, but it is helpful to hear feature requests. One thing that can help us as we prioritize feature requests is if you can describe your use case a bit more.

That said, it's not initially obvious to me what behavior IdentityServer actually should have in a "non-OIDC" mode. RFC 7523 says that the OP MAY use JTI to prevent replay, and we choose to do so. It wouldn't be possible to do that without a JTI, so it seems reasonable to require it if we're going to do replay detection. So this "non-oidc" mode flag would be turning that off, I guess? The issuer could theoretically be something other than the client id, but what value would you use? I suppose new config could control the expected issuer, but that new config should only be applicable in this mode. I would also assume that it is an error to configure a client with identity resource scopes and this non-oidc flag. And I'm sure there are other places where the OAuth specs make things optional or leave them to the digression of the authorization server but OIDC makes mandatory or explicit. We would have to review things pretty carefully to make sure we catch as much of that kind of thing as possible, and decide what the behavior should be if oidc is disabled. So my initial reaction is that this seems like it would take quite a bit of effort to add to IdentityServer itself. It also seems like there are enough places where things can be customized that you could get this behavior if you really needed it today.

brockallen commented 11 months ago

For historical context, we've always been OIDC focused. So when there have been ambiguities between OIDC and OAuth, we've typically landed on the OIDC spec side of things.

b3n3d17 commented 11 months ago

See below my attempt at briefly describing the use case:

You work with the following:

  1. You have a k8s cluster.
  2. You run microservices in the k8s cluster.
  3. Each microservice is a confidential oauth2.0 client.
  4. You choose to provide client_ids and client_secrets as k8s secrets to each an every microservice.

Your requirements are:

  1. rotate your secrets
  2. have short lifetimes of your credentials

Your solution:

  1. You choose to use service account token volume projection https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/#serviceaccount-token-volume-projection This is a k8s feature which issues JWTs to your workload where you can define the audience (very short explanation). These credentials are rotated automatically before their expiration. These token are issued by the kubernetes api server.
  2. You configure each microservice with a serviceaccount.
  3. You configure each serviceaccount in duende identity server as confidential client and reffere to the JWK of the api server (public key of the service account token).

The result: You have microservices with automagically rotating client credentials, which are short lived, and which you dont have to manage yourself.

The limitation:

  1. k8s is the issuer -> hence the iss==sub validation fails
  2. k8s does not add a jti
    1. This is also not possible in my opinion, as k8s does not "know" when you use the token somewhere. So how would you get a new token afer each use?

My "current" PoC involves writing (as you described earlier - thank you for that) a new validator. Basically hard coding the issuer validation to the k8s issuer and removing the code where the jti is validated. However, my initial "feeling" is that for "production" it would be nicer to add this to the client configuration itselfe.

Maybe a oidc=false switch and then iss = .... .

Did that help in describing the use case, and on that note do you also see the benefit of this use case compared to using client_id and secrets?

josephdecock commented 11 months ago

Thanks for the description - it's always interesting to hear how people are using IdentityServer! The way that you're customizing things seems like a good example of how we envision the flexibility of IdentityServer - a mix of configuration and built-in features AND the ability to replace functionality through custom code. This really feels like your requirements and design are specialized enough that it should be a customization. If you run into limitations in the abstractions that create problems as you're implementing the customization, we'd really like to hear about that though.

AndersAbel commented 11 months ago

Are there any more open questions on this issue or can we go ahead and close it?