Skip to content

invalid_request: Unexpected client assertion type, when using certificates based authorization #1661

@kazistevan

Description

@kazistevan

Describe the bug

Hello, I'm trying to use certificates (self-signed) based authorization for getting access tokens on behalf of user (their outlook calendar resources).

I'm using AuthorizationCodeCertificateContext to acquire initial access and refresh tokens, then I store them in db for later usage.

Using stored access token works well until it expires, when I get invalid_request error when sdk tries to refresh access token, again using stored refresh token

I'm not sure how am I able to get access and refresh tokens using AuthorizationCodeCertificateContext, then use access token successfully to fetch some user resources, but not able to refresh access token.

Expected behavior

If I understand token management correctly, expected behavior should be that refreshing token just works out of the box if I provide refresh token to InMemoryAccessTokenCache.

I tried same thing using secrets instead of certificates, it works as expected.

Also checked if sdk actually is trying to refresh token and is sending proper request, it should be okay as well:

method: POST

url: https://login.microsoftonline.com/{my-tenant-id}/oauth2/v2.0/token

headers: [
    "content-type" => "application/x-www-form-urlencoded"
  ]

body:
"client_id" => "abc"
"grant_type" => "refresh_token"
"client_assertion" => "eyJ0eXAiOiJKV1QiLCJ4NXQiOiJXUklac2RPRE9WaHlxTWFoQ2VfYUxnTHdGTE0iLCJhbGciOiJSUzI1NiJ9.eyJhdWQiOiJodHRwczovL2xvZ2luLm1pY3Jvc29mdG9ubGluZS5jb20vZTU0OGZkOWQtZmJmZC0..."
"client_assertion_type" => "urn:ietf:params:Oauth:client-assertion-type:jwt-bearer"
"refresh_token" => "1.AR8Anf1I5f372U6UbFh_vPA_wn8-2g2i6gpHmbfGx5rrSzccAfUfAA.AgABAwEAAABVrSpeuWamRam2jAF1XRQEAwDs_..."

How to reproduce

this is how I created self-signed CA:

$privateKeyOptions = [
            'digest_alg'       => OPENSSL_ALGO_SHA256,
            'private_key_bits' => 2048,
            'private_key_type' => OPENSSL_KEYTYPE_RSA,
        ];

        // Generate a new private key
        $privateKey = openssl_pkey_new($privateKeyOptions);

        if ($privateKey === false) {
            throw new RuntimeException(sprintf('Failed to generate private key: %s', openssl_error_string()));
        }

        // Generate a certificate signing request
        $csr = openssl_csr_new([
            'countryName'         => 'xx',
            'stateOrProvinceName' => 'xxxx',
            'localityName'        => 'xx',
            'organizationName'    => 'xx',
            'commonName'          => 'localhost',
        ], $privateKey, $privateKeyOptions);

        if (is_bool($csr)) {
            throw new RuntimeException(sprintf('Failed to open RSA key: %s', openssl_error_string()));
        }

        // Generate a self-signed certificate valid for 1 year
        $certificate = openssl_csr_sign(
            csr: $csr,
            ca_certificate: null,
            private_key: $privateKey,
            days: CertificateRotatorInterface::MAX_DURATION_IN_DAYS
        );

        if ($certificate === false) {
            throw new RuntimeException(sprintf('Failed to sign data: %s', openssl_error_string()));
        }

        // Export the certificate and key to files
        $certificateFilePath = '/tmp/tls_crt.pem';
        $privateKeyFilePath  = '/tmp/tls_key.pem';

        if (!openssl_x509_export_to_file($certificate, $certificateFilePath)) {
            throw new RuntimeException(sprintf('Failed to export certificate: %s', openssl_error_string()));
        }

        if (!openssl_pkey_export_to_file($privateKey, $privateKeyFilePath)) {
            throw new RuntimeException(sprintf('Failed to export private key: %s', openssl_error_string()));
        }

tls_crt.pem file is uploaded through Microsoft Entra Admin to an application I registered for testing.

GraphServiceClient setup:

(I use "code_verifier" while getting authorization code, here it's just some placeholder value)

$tokenRequestContext = new AuthorizationCodeCertificateContext(
      tenantId: $tenantId,
      clientId: $clientId,
      authCode: 'default',
      redirectUri: $redirectUri,
      certificatePath: $certificatePath,
      privateKeyPath: $privateKeyPath,
      additionalParams: ['code_verifier' => 'default']
  );

$inMemoryCache = new InMemoryAccessTokenCache(
    $tokenRequestContext,
    new AccessToken([
        'access_token'  => $token['accessToken'],
        'refresh_token' => $token['refreshToken'],
        'expires'       => $token['expires'],
    ])
);

$graphServiceClient = GraphServiceClient::createWithAuthenticationProvider(
    GraphPhpLeagueAuthenticationProvider::createWithAccessTokenProvider(
        GraphPhpLeagueAccessTokenProvider::createWithCache(
            $accessTokenCache,
            $tokenRequestContext,
            $scopes
        )
    )
);

dump($graphServiceClient->me()->get()->wait());

SDK Version

2.30.0

Latest version known to work for scenario above?

No response

Known Workarounds

No response

Debug output

League\OAuth2\Client\Provider\Exception\IdentityProviderException {
  #response: array:6 [
    "error" => "invalid_request"
    "error_description" => "AADSTS90023: Unexpected client assertion type. Trace ID: a0dfae09-fd6a... Correlation ID: ae87cdb5-9cb1-4a43... Timestamp: 2025-03-24 14:15:17Z "
    "error_codes" => array:1 [
      0 => 90023
    ]
    "timestamp" => "2025-03-24 14:15:17Z"
    "trace_id" => "a0dfae09-fd6a-482f..."
    "correlation_id" => "ae87cdb5-9cb1-4a43..."
  ]
}

Configuration

docker base image: php:8.4-fpm-alpine3.21

Other information

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    status:waiting-for-triageAn issue that is yet to be reviewed or assignedtype:bugA broken experience

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions