Skip to content

Support JWT Client Assertion for OAuth2 Authentication #311

@rhamzeh

Description

@rhamzeh

Checklist

Describe the problem you'd like to have solved

The Java SDK currently only supports the client_credentials OAuth2 grant with client_secret for authentication against the token endpoint. Some enterprise environments have security policies that forbid the use of shared secrets (client_secret) for machine-to-machine authentication and instead require JWT-based client assertions (See RFC 7523 (Section 2.2)).

Users in these environments are currently unable to use the Java SDK's built-in credential management at all, they're forced to either manage token acquisition entirely outside the SDK or forego using the SDK.

Describe the ideal solution

Add support for JWT client assertions as an alternative client authentication method on the existing client_credentials grant, consistent with the approach we've taken in the JS SDK (PR #228).

The token request would use:

grant_type=client_credentials
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=<signed JWT>
&client_id=<client_id>
&audience=<audience>

History | What the JS SDK Did

In PR #228 , the JS SDK added support for JWT client assertions as an alternative to client_secret for authenticating the client_credentials grant.

The JS SDK introduced:

  • A PrivateKeyJWTConfig type alongside the existing ClientSecretConfig (unified as ClientCredentialsConfig = ClientSecretConfig | PrivateKeyJWTConfig)
  • Two new config fields: clientAssertionSigningKey (a PEM-encoded PKCS#8 private key string) and clientAssertionSigningAlgorithm (defaults to RS256)
  • Internal signing logic that builds and signs the JWT assertion inside the SDK using the jose library (added as a new dependency)

The interface in JS was:

type PrivateKeyJWTConfig = BaseClientCredentialsConfig & {
  clientAssertionSigningKey: string;       // PEM-encoded PKCS#8 private key
  clientAssertionSigningAlgorithm?: string; // defaults to RS256
}

type ClientCredentialsConfig = ClientSecretConfig | PrivateKeyJWTConfig;

Then the SDK builds & signs the JWT

const assertion = await new jose.SignJWT({})
    .setProtectedHeader({ alg })
    .setIssuedAt()
    .setSubject(config.clientId)
    .setJti(randomUUID())
    .setIssuer(config.clientId)
    .setAudience(`https://${config.apiTokenIssuer}/`)
    .setExpirationTime("2m")
    .sign(privateKey);

Limitations of the JS Approach

  • In some enterprise environments, the key cannot be exported - e.g. KMS, Vault..
  • If you need to rotate the key, you need to rebuild the configuration
  • We had to add a dependency on the jose library, even though most consumers of the SDK do not need it
  • It is common to load keys from a keystore, rather than passing them as strings

Proposed interface

We should replacing the PEM string with a provider interface — a callback that returns a signed JWT assertion on demand. The SDK continues to own the token exchange lifecycle (caching, refresh, retry) but delegates assertion creation entirely to the caller.

Note

Below you'll see references to Scopes as defined by RFC 6749. This is not strictly related to Private Key JWT, but is an adjacent feature supported by several other SDKs that we need to add

public class ClientCredentialsConfig {
    // Shared fields
    private String clientId;
    private String apiTokenIssuer;
    private String apiAudience; // Optional. This is required by issuers such as Auth0.
    private Set<String> scopes; // Optional. As defined by RFC 6749.
    private Map<String, String> customClaims; // Optional custom claims.

    // Existing client_secret authentication
    private String clientSecret;

    // JWT client assertion authentication
    private ClientAssertionProvider clientAssertionProvider;
}

ClientAssertionProvider can be something like:

public interface ClientAssertionProvider {
    String getJwt() throws AuthenticationException;
}

When clientAssertion is present, the SDK should:

  • Send client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer and client_assertion=<jwt> in the token request
  • Omit client_secret
  • Continue to handle token caching/refresh as it does today

Validation rules:

  • Either clientSecret or clientAssertion must be provided, but not both
  • If clientAssertion is provided, either jwt or jwtProvider must be set
  • clientId remains required

To build the request params, the fn could be:

private Map<String, String> buildTokenRequestParams(
        ClientCredentialsConfig config) throws Exception {

    var params = new LinkedHashMap<String, String>();
    params.put("grant_type", "client_credentials");
    params.put("client_id", config.getClientId());

    if (config.getCustomClaims() != null) {
        params.putAll(config.getCustomClaims());
    }

    if (config.getApiAudience() != null) {
        params.put("audience", config.getApiAudience());
    }

    if (config.getScopes() != null && !config.getScopes().isEmpty()) {
        params.put("scope", String.join(" ", config.getScopes()));
    }

    if (config.getClientAssertionProvider() != null) {
        params.put("client_assertion",
            config.getClientAssertionProvider().getJwt());
        params.put("client_assertion_type",
            "urn:ietf:params:oauth:client-assertion-type:jwt-bearer");
    } else if (config.getClientSecret() != null) {
        params.put("client_secret", config.getClientSecret());
    } else {
        throw new FgaValidationException(
            "Either clientSecret or clientAssertionProvider is required");
    }

    return params;
}

External DX

Usage would look like:

var config = new ClientConfiguration()
    .apiUrl("https://api.fga.example")
    .credentials(new CredentialConfiguration()
        .method(CredentialMethod.CLIENT_CREDENTIALS)
        .clientCredentials(new ClientCredentialsConfig()
            .clientId("my-service")
            .apiTokenIssuer("https://auth.fga.example")
            .apiAudience("https://api.fga.example/")
            .clientAssertion(new ClientAssertionConfig()
                .jwtProvider(() -> buildSignedJwt(privateKey))
            )
        )
    );

External DX - example w/ KMS

.clientCredentials(new ClientCredentialsConfig()
    .clientId("my-service")
    .apiTokenIssuer("auth.fga.example")
    .apiAudience("https://api.fga.example/")
    .clientAssertionProvider(() -> {
        String signingInput = buildHeaderAndClaims();
        SignRequest req = SignRequest.builder()
            .keyId("arn:aws:kms:us-east-1:123456789:key/some-random-key-id")
            .message(SdkBytes.fromByteArray(
                signingInput.getBytes(UTF_8)))
            .signingAlgorithm(
                SigningAlgorithmSpec.RSASSA_PKCS1_V1_5_SHA_256)
            .messageType(MessageType.RAW)
            .build();
        byte[] sig = kmsClient.sign(req).signature().asByteArray();
        return signingInput + "." + base64url(sig);
    })
)

Alternatives and current workarounds

Users can acquire tokens outside the SDK and pass them via the API_TOKEN credential method. However, this means the user must handle token refresh/expiry themselves, losing the built-in token lifecycle management we provide through the SDK.

References

Additional context

Notes:

  • This should also be considered for the other language SDKs (Go, .NET, Python) for consistency.
  • This pattern should be backported to the JS SDK and the use of the jose library deprecated so we can remove it later as folks transition
  • We are adding scopes here, which has been implemented in SDKs such as Go, but not yet in JS

Original request:
Author: Nicholas on CNCF Slack (thread)

Oauth2 Flow in OpenFga Java Client SDK.
I can see in the source code of the SDK the implemented Oauth2 flow is "Client Credentials".
My company forbid this specific flow.
They allow "Jwt As Authorization Code Grant" between two backend components.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    Status

    Intake

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions