Skip to content
113 changes: 105 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Connect protocol to setup authentication.
- [RFC 7009: OAuth 2.0 Token Revocation](https://tools.ietf.org/html/rfc7009)
- [RFC 7636: Proof Key for Code Exchange by OAuth Public Clients](https://tools.ietf.org/html/rfc7636)
- [RFC 7662: OAuth 2.0 Token Introspection](https://tools.ietf.org/html/rfc7662)
- [RFC 8705: OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens](https://tools.ietf.org/html/rfc8705)
- [Draft: OAuth 2.0 Authorization Server Issuer Identifier in Authorization Response](https://tools.ietf.org/html/draft-ietf-oauth-iss-auth-resp-00)

## Tested providers
Expand Down Expand Up @@ -74,9 +75,9 @@ $oidc->register();
```

### Example 3: Network and Security
You should always use HTTPS for your application. If you are using a self-signed certificate, you can disable the SSL
verification by setting the `verify_ssl` property on the client and, if you have it, set a custom certificate in the `cert_path` property
(this works only if verifySsl is set to false).
You should always use HTTPS for your application. If your provider uses a private CA or a self-signed certificate, set
`cert_path` to the CA bundle: the connection is then still fully verified, just against your own CA rather than the
system trust store. A bundle set this way is always used, so you do not need to touch `verify_ssl`.

You can also setup a proxy via the `http_proxy`.

Expand All @@ -89,11 +90,18 @@ $oidc = new Client(
client_secret: 'ClientSecretHere',
redirect_uri: 'https://example.com/callback.php',
http_proxy: 'http://proxy.example.com:8080',
cert_path: 'path/to/cert.pem',
verify_ssl: false
cert_path: 'path/to/ca.pem'
);
```

Setting `verify_ssl: false` disables certificate verification entirely, which exposes tokens and the client secret to
anyone on the network path. Prefer `cert_path`, and see [Development Environments](#development-environments) if you
really need to turn verification off.

> **Note:** in earlier versions `cert_path` only took effect when `verify_ssl` was `false`. It is now honoured in both
> cases, so `cert_path: 'path/to/ca.pem', verify_ssl: false` keeps verifying against the bundle rather than silently
> trusting any certificate.

### Example 4: Implicit flow
> Reference: https://openid.net/specs/openid-connect-core-1_0.html#ImplicitFlowAuth

Expand Down Expand Up @@ -182,10 +190,100 @@ $oidc = new Client(

**Note: A JWT generator is not included in this library yet.**

### Example 8: Mutual-TLS client authentication (RFC 8705)
Instead of a client secret, the client can authenticate with a certificate presented during the TLS
handshake. Pass the certificate through `mtls_certificate` and pick one of the two mutual-TLS methods:
`ClientAuthMethod::TLS_CLIENT_AUTH` (the certificate is issued by a CA the provider trusts) or
`ClientAuthMethod::SELF_SIGNED_TLS_CLIENT_AUTH` (the provider holds the certificate itself).

```php
use Maicol07\OpenIDConnect\Client;
use Maicol07\OpenIDConnect\ClientAuthMethod;
use Maicol07\OpenIDConnect\MutualTlsCertificate;

$oidc = new Client(
provider_url: 'https://id.example.com',
client_id: 'ClientIDHere',
// No client_secret: the certificate authenticates the client
redirect_uri: 'https://example.com/callback.php',
token_endpoint_auth_method: ClientAuthMethod::TLS_CLIENT_AUTH,
mtls_certificate: new MutualTlsCertificate(
certificate_path: '/path/to/client.crt',
private_key_path: '/path/to/client.key',
// passphrase: 'private key passphrase, if it is encrypted'
),
);
$oidc->authenticate();
```

The certificate is presented on every request to the provider, and when the provider publishes
[`mtls_endpoint_aliases`](https://tools.ietf.org/html/rfc8705#section-5) those endpoints are used
automatically. If `token_endpoint_auth_method` is omitted, a mutual-TLS method announced by the
provider is selected only when a certificate is configured, so existing secret-based clients are
unaffected.

If the provider's certificate is issued by a private CA, point `cert_path` at the CA bundle so the
connection is still verified:

```php
$oidc = new Client(
// ...
cert_path: '/path/to/ca.crt',
);
```

When registering a mutual-TLS client dynamically, `register()` sends `token_endpoint_auth_method`
and `tls_client_certificate_bound_access_tokens` for you, but RFC 8705 asks for metadata that only
you can supply. Pass it through the `register()` parameters, otherwise the provider will reject the
registration with an opaque error:

- `tls_client_auth` requires exactly one subject identifier, such as `tls_client_auth_subject_dn` or
`tls_client_auth_san_dns` ([section 2.1.2](https://tools.ietf.org/html/rfc8705#section-2.1.2))
- `self_signed_tls_client_auth` requires `jwks` or `jwks_uri`
([section 2.2.2](https://tools.ietf.org/html/rfc8705#section-2.2.2))

```php
$oidc->register([
'tls_client_auth_subject_dn' => 'CN=my-client,O=Example,C=US',
]);
```

### Example 9: Certificate-bound access tokens (RFC 8705 section 3)
A certificate-bound access token can only be used by the client holding the private key, so a stolen
token is useless on its own. Providers advertise this through
`tls_client_certificate_bound_access_tokens` in their discovery document, which is picked up
automatically; the token then carries a `cnf` claim with the `x5t#S256` thumbprint of the certificate.

`verifyCertificateBinding()` checks that such a token is bound to the configured certificate. It
returns `false` when the token is not bound at all and throws an `OIDCClientException` when it is
bound to a different certificate:

```php
use Maicol07\OpenIDConnect\OIDCClientException;

// Claims of an access token, or an introspection response
$claims = $oidc->introspectToken($access_token);

try {
if ($oidc->verifyCertificateBinding($claims)) {
// The token is bound to our certificate
}
} catch (OIDCClientException $e) {
// The token is bound to a different certificate — do not use it
}
```

Note that the authoritative check belongs to the resource server, which compares the thumbprint in
the token against the certificate presented to it.

## Development Environments

Sometimes you may need to disable SSL security on your development systems. You can do it by calling the `verify` method
with the `false` parameter. Note: This is not recommended on production systems.
Sometimes you may need to disable SSL security on your development systems. You can do it by setting `verify_ssl` to
`false`. Note: This is not recommended on production systems.

If you only need to trust a private CA or a self-signed provider certificate, use `cert_path` instead (see
[Example 3](#example-3-network-and-security)): it keeps the connection verified. Note that `verify_ssl: false` only
disables verification when no `cert_path` is set — a bundle given there is always honoured.

```php
use Maicol07\OpenIDConnect\Client;
Expand Down Expand Up @@ -217,7 +315,6 @@ To run the tests, you need to have a running OpenID Connect provider
3. Go to Credentials tab and copy the Secret
4. Tweak the PHPStorm Run configuration with your settings.


### Todo
- Dynamic registration does not support registration auth tokens and endpoints

Expand Down
32 changes: 25 additions & 7 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
use Maicol07\OpenIDConnect\Traits\DynamicRegistration;
use Maicol07\OpenIDConnect\Traits\ImplicitFlow;
use Maicol07\OpenIDConnect\Traits\JWT;
use Maicol07\OpenIDConnect\Traits\MutualTls;
use Maicol07\OpenIDConnect\Traits\Token;
use SensitiveParameter;

Expand All @@ -44,6 +45,7 @@ class Client
use DynamicRegistration;
use ImplicitFlow;
use JWT;
use MutualTls;

private string $access_token;
private string $id_token;
Expand Down Expand Up @@ -71,9 +73,12 @@ class Client
* @param string|null $jwks_endpoint JWKS endpoint of the provider (can be null if you use auto discovery)
* @param bool $authorization_response_iss_parameter_supported Allow iss parameter in authorization response. Defaults to false - @see http://openid.net/specs/openid-connect-core-1_0.html#AuthResponseValidation
* @param ClientAuthMethod[] $token_endpoint_auth_methods_supported Supported client authentication methods for token endpoint (can be empty if you use auto discovery)
* @param ClientAuthMethod|null $token_endpoint_auth_method Client authentication method to use for the token endpoint. Defaults to null, which picks a method from the supported ones - @see https://tools.ietf.org/html/rfc8705
* @param MutualTlsCertificate|null $mtls_certificate Client certificate to present during the TLS handshake, for mutual-TLS client authentication and certificate-bound access tokens - @see https://tools.ietf.org/html/rfc8705
* @param bool $tls_client_certificate_bound_access_tokens Request certificate-bound access tokens (can be false if you use auto discovery) - @see https://tools.ietf.org/html/rfc8705#section-3
* @param string|null $http_proxy HTTP proxy to use for requests (can be null if you don't want to use a proxy)
* @param string|null $cert_path Path to a custom certificate to use for requests (can be null if you don't want to use a custom certificate)
* @param bool $verify_ssl Verify SSL certificates when making requests. Defaults to true.
* @param string|null $cert_path Path to a CA bundle to verify the provider's certificate against, for providers using a private CA (can be null to use the system trust store). When set it is always used, even if $verify_ssl is false.
* @param bool $verify_ssl Verify SSL certificates when making requests. Defaults to true. Setting this to false disables verification entirely and is unsafe outside development; to trust a private CA use $cert_path instead.
* @param int $timeout Timeout for requests. Defaults to 0.
* @param string $client_name Name of the client for dynamic registration (can be null if you have already registered the client)
* @param bool $allow_implicit_flow Allow OAuth 2 implicit flow. - @see http://openid.net/specs/openid-connect-core-1_0.html#ImplicitFlowAuth
Expand Down Expand Up @@ -105,6 +110,9 @@ public function __construct(
public ?string $jwt_audience = null,
public bool $authorization_response_iss_parameter_supported = false,
public array $token_endpoint_auth_methods_supported = [],
public ?ClientAuthMethod $token_endpoint_auth_method = null,
public readonly ?MutualTlsCertificate $mtls_certificate = null,
public bool $tls_client_certificate_bound_access_tokens = false,
public readonly ?string $http_proxy = null,
public readonly ?string $cert_path = null,
public readonly bool $verify_ssl = true,
Expand Down Expand Up @@ -245,13 +253,16 @@ public function requestTokenExchange(
$data['audience'] = $audience;
}

# Consider Basic authentication if provider config is set this way
if (in_array(ClientAuthMethod::CLIENT_SECRET_BASIC, $this->token_endpoint_auth_methods_supported, true)) {
# With mutual TLS the certificate authenticates the client, so no secret is sent
if (!$this->applyMutualTlsClientAuth($data)
# Consider Basic authentication if provider config is set this way
&& in_array(ClientAuthMethod::CLIENT_SECRET_BASIC, $this->token_endpoint_auth_methods_supported, true)
) {
$client = $client->withBasicAuth($this->client_id, $this->client_secret);
unset($data['client_secret'], $data['client_id']);
}

return $client->post($this->token_endpoint, $data)->collect();
return $client->post($this->mtlsEndpoint('token', $this->token_endpoint), $data)->collect();
}

/**
Expand All @@ -262,7 +273,7 @@ public function requestTokenExchange(
public function getUserInfo(): UserInfo
{
// Extract query parameters from the userinfo endpoint
$parts = parse_url($this->userinfo_endpoint);
$parts = parse_url($this->mtlsEndpoint('userinfo', $this->userinfo_endpoint));
if ($parts === false) {
throw new OIDCClientException('The userinfo endpoint URL is invalid');
}
Expand Down Expand Up @@ -326,7 +337,14 @@ private function client(): PendingRequest
->withOptions([
'connect_timeout' => $this->timeout,
'proxy' => $this->http_proxy,
'verify' => ($this->verify_ssl ?: $this->cert_path) ?? false
// A custom CA bundle always wins: verifying against it is strictly safer than
// not verifying, and it stays honoured even alongside `verify_ssl: false`, which
// earlier versions required to make `cert_path` take effect at all. Verification
// is therefore only disabled when it is turned off *and* no bundle is given.
'verify' => $this->cert_path ?? $this->verify_ssl,
// Present the client certificate during the TLS handshake, for mutual-TLS client
// authentication and certificate-bound access tokens (RFC 8705)
...$this->mtls_certificate?->getRequestOptions() ?? []
]);
}

Expand Down
25 changes: 25 additions & 0 deletions src/ClientAuthMethod.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,30 @@ enum ClientAuthMethod: string
case CLIENT_SECRET_POST = 'client_secret_post';
case CLIENT_SECRET_JWT = 'client_secret_jwt';
case PRIVATE_KEY_JWT = 'private_key_jwt';

/**
* PKI mutual-TLS client authentication.
*
* @see https://tools.ietf.org/html/rfc8705#section-2.1
*/
case TLS_CLIENT_AUTH = 'tls_client_auth';

/**
* Self-signed certificate mutual-TLS client authentication.
*
* @see https://tools.ietf.org/html/rfc8705#section-2.2
*/
case SELF_SIGNED_TLS_CLIENT_AUTH = 'self_signed_tls_client_auth';
case NONE = 'none';

/**
* Whether this method authenticates the client through the TLS layer
* rather than through a shared secret or an assertion.
*
* @see https://tools.ietf.org/html/rfc8705#section-2
*/
public function isMutualTls(): bool
{
return $this === self::TLS_CLIENT_AUTH || $this === self::SELF_SIGNED_TLS_CLIENT_AUTH;
}
}
109 changes: 109 additions & 0 deletions src/MutualTlsCertificate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<?php
/*
* Copyright © 2026 Maicol07 (https://maicol07.it)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may get a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License
* is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
* either express or implied. See the License for the specific language governing permissions and
* limitations under the License.
*/

namespace Maicol07\OpenIDConnect;

use SensitiveParameter;

/**
* The client certificate (and its private key) presented during the TLS handshake
* for mutual-TLS client authentication and certificate-bound access tokens.
*
* @see https://tools.ietf.org/html/rfc8705
*/
class MutualTlsCertificate
{
/**
* @param string $certificate_path Path to the PEM encoded client certificate
* @param string|null $private_key_path Path to the PEM encoded private key. Can be null if the
* key is bundled in the certificate file.
* @param string|null $passphrase Passphrase of the private key (or of the certificate when the
* key is bundled), if it is encrypted
* @throws OIDCClientException If the certificate or the private key cannot be read
* @noinspection SensitiveParameterInspection
*/
public function __construct(
public readonly string $certificate_path,
public readonly ?string $private_key_path = null,
#[SensitiveParameter] public readonly ?string $passphrase = null
) {
if (!is_readable($this->certificate_path)) {
throw new OIDCClientException(
"The client certificate at \"{$this->certificate_path}\" does not exist or is not readable"
);
}

if ($this->private_key_path !== null && !is_readable($this->private_key_path)) {
throw new OIDCClientException(
"The client private key at \"{$this->private_key_path}\" does not exist or is not readable"
);
}
}

/**
* Guzzle request options that make the certificate be presented during the TLS handshake.
*
* @return array<string, string|array<int, string>>
*/
public function getRequestOptions(): array
{
$options = [
'cert' => $this->passphrase === null
? $this->certificate_path
: [$this->certificate_path, $this->passphrase]
];

if ($this->private_key_path !== null) {
$options['ssl_key'] = $this->passphrase === null
? $this->private_key_path
: [$this->private_key_path, $this->passphrase];
}

return $options;
}

/**
* The certificate SHA-256 thumbprint, base64url encoded, as used in the `cnf` claim
* `x5t#S256` confirmation method of a certificate-bound access token.
*
* @throws OIDCClientException If the certificate cannot be read or parsed
* @see https://tools.ietf.org/html/rfc8705#section-3.1
*/
public function getThumbprint(): string
{
$contents = file_get_contents($this->certificate_path);
if ($contents === false) {
throw new OIDCClientException(
"Unable to read the client certificate at \"{$this->certificate_path}\""
);
}

// The file may bundle the private key (and intermediates) alongside the certificate,
// so hash the first certificate block only.
$der = null;
if (preg_match('/-----BEGIN CERTIFICATE-----(.+?)-----END CERTIFICATE-----/s', $contents, $matches)) {
$der = base64_decode((string) preg_replace('/\s+/', '', $matches[1]), true);
}

if (empty($der)) {
throw new OIDCClientException(
"Unable to parse the client certificate at \"{$this->certificate_path}\""
);
}

return rtrim(strtr(base64_encode(hash('sha256', $der, true)), '+/', '-_'), '=');
}
}
9 changes: 9 additions & 0 deletions src/Traits/AutoDiscovery.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,15 @@ public function autoDiscovery(?string $provider_url, array|string|null $query_pa
}

$this->introspect_endpoint ??= $config->get('introspection_endpoint');

// Mutual-TLS endpoint aliases and certificate-bound access tokens (RFC 8705)
$aliases = $config->get('mtls_endpoint_aliases', []);
if (empty($this->mtls_endpoint_aliases) && is_array($aliases)) {
$this->mtls_endpoint_aliases = array_filter($aliases, 'is_string');
}

$this->tls_client_certificate_bound_access_tokens = $this->tls_client_certificate_bound_access_tokens
|| $config->get('tls_client_certificate_bound_access_tokens', false) === true;
}
}
}
Expand Down
Loading