diff --git a/README.md b/README.md index 8c2983d1..865b6e11 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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`. @@ -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 @@ -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; @@ -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 diff --git a/src/Client.php b/src/Client.php index 60ae39b3..4e973cea 100644 --- a/src/Client.php +++ b/src/Client.php @@ -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; @@ -44,6 +45,7 @@ class Client use DynamicRegistration; use ImplicitFlow; use JWT; + use MutualTls; private string $access_token; private string $id_token; @@ -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 @@ -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, @@ -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(); } /** @@ -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'); } @@ -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() ?? [] ]); } diff --git a/src/ClientAuthMethod.php b/src/ClientAuthMethod.php index db61b774..d60c39cb 100644 --- a/src/ClientAuthMethod.php +++ b/src/ClientAuthMethod.php @@ -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; + } } diff --git a/src/MutualTlsCertificate.php b/src/MutualTlsCertificate.php new file mode 100644 index 00000000..48b32230 --- /dev/null +++ b/src/MutualTlsCertificate.php @@ -0,0 +1,109 @@ +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> + */ + 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)), '+/', '-_'), '='); + } +} diff --git a/src/Traits/AutoDiscovery.php b/src/Traits/AutoDiscovery.php index 33268d5b..0392394e 100644 --- a/src/Traits/AutoDiscovery.php +++ b/src/Traits/AutoDiscovery.php @@ -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; } } } diff --git a/src/Traits/DynamicRegistration.php b/src/Traits/DynamicRegistration.php index 3887c289..3d452407 100644 --- a/src/Traits/DynamicRegistration.php +++ b/src/Traits/DynamicRegistration.php @@ -32,7 +32,18 @@ public function register(?array $params = null): void ->put('redirect_uris', [$this->redirect_uri]) ->put('client_name', $this->client_name); - $response = $this->client()->post($this->registration_endpoint, $data->all())->collect(); + // Mutual-TLS client authentication and certificate-bound access tokens (RFC 8705) + $auth_method = $this->getClientAuthMethod(); + if ($auth_method?->isMutualTls()) { + $data->put('token_endpoint_auth_method', $auth_method->value); + } + if ($this->tls_client_certificate_bound_access_tokens) { + $data->put('tls_client_certificate_bound_access_tokens', true); + } + + $response = $this->client() + ->post($this->mtlsEndpoint('registration', $this->registration_endpoint), $data->all()) + ->collect(); $error = $response->get('error_description'); if ($error) { @@ -46,7 +57,8 @@ public function register(?array $params = null): void $secret = $response->get('client_secret'); if ($secret) { $this->client_secret = $secret; - } else { + } elseif (!$auth_method?->isMutualTls()) { + // Mutual-TLS clients are authenticated by their certificate, so they have no secret throw new OIDCClientException('Error registering: Please contact the OpenID Connect provider and obtain a Client ID and Secret directly from them'); } diff --git a/src/Traits/MutualTls.php b/src/Traits/MutualTls.php new file mode 100644 index 00000000..0f6b4ded --- /dev/null +++ b/src/Traits/MutualTls.php @@ -0,0 +1,175 @@ + + * @see https://tools.ietf.org/html/rfc8705#section-5 + */ + private array $mtls_endpoint_aliases = []; + + /** + * Whether the client authenticates itself at the token endpoint through mutual TLS. + * + * @see https://tools.ietf.org/html/rfc8705#section-2 + */ + public function usesMutualTlsClientAuth(): bool + { + return $this->mtls_certificate !== null && $this->getClientAuthMethod()?->isMutualTls() === true; + } + + /** + * The mutual-TLS client authentication method to use, if any. + * + * An explicitly configured method always wins. Otherwise a mutual-TLS method announced by + * the provider is only selected when a certificate is actually configured — a provider + * advertising `tls_client_auth` must never push a client that has no certificate (a plain + * client secret one, say) onto a method it cannot possibly use. + */ + private function getClientAuthMethod(): ?ClientAuthMethod + { + if ($this->token_endpoint_auth_method instanceof ClientAuthMethod) { + return $this->token_endpoint_auth_method; + } + + if ($this->mtls_certificate === null) { + return null; + } + + // The supported methods are a public array, so a caller may well have passed method + // names as strings rather than enum cases. + return collect($this->token_endpoint_auth_methods_supported) + ->map(static fn (mixed $method): ?ClientAuthMethod => $method instanceof ClientAuthMethod + ? $method + : (is_string($method) ? ClientAuthMethod::tryFrom($method) : null)) + ->first(static fn (?ClientAuthMethod $method): bool => $method?->isMutualTls() === true); + } + + /** + * Resolves an endpoint against the provider's mutual-TLS aliases. + * + * When the client authenticates with mutual TLS — or asks for certificate-bound tokens — + * the aliased endpoint must be used instead of the regular one, as the aliases are + * served on a host/port that requests a client certificate during the handshake. + * + * @param string $name The endpoint name without the `_endpoint` suffix, e.g. `token` + * @param string|null $endpoint The regular endpoint URL + * @see https://tools.ietf.org/html/rfc8705#section-5 + */ + private function mtlsEndpoint(string $name, ?string $endpoint): ?string + { + if (!$this->usesMutualTls()) { + return $endpoint; + } + + return $this->mtls_endpoint_aliases["{$name}_endpoint"] ?? $endpoint; + } + + /** + * Whether requests to the provider go through mutual TLS, either to authenticate the client + * or to obtain certificate-bound access tokens. + * + * Both cases require the aliased endpoints: they are served on a host/port that requests a + * client certificate during the handshake. Certificate-bound tokens are issued to the + * certificate presented at the token endpoint, so they need it even when the client + * authenticates with a secret - @see https://tools.ietf.org/html/rfc8705#section-3 + */ + private function usesMutualTls(): bool + { + return $this->usesMutualTlsClientAuth() + || ($this->mtls_certificate !== null && $this->tls_client_certificate_bound_access_tokens); + } + + /** + * Applies mutual-TLS client authentication to a token endpoint request. + * + * With mutual TLS the client is authenticated by the certificate presented during the + * handshake, so the client secret must not be sent; `client_id` is still required to + * identify the client - @see https://tools.ietf.org/html/rfc8705#section-2 + * + * @param array $data The request body, modified in place + * @throws OIDCClientException If mutual-TLS authentication is configured without a certificate + */ + private function applyMutualTlsClientAuth(array &$data): bool + { + $method = $this->getClientAuthMethod(); + if ($method === null || !$method->isMutualTls()) { + return false; + } + + if ($this->mtls_certificate === null) { + throw new OIDCClientException( + "The \"{$method->value}\" client authentication method requires a client certificate. " + . 'Pass one through the "mtls_certificate" parameter.' + ); + } + + unset($data['client_secret']); + $data['client_id'] = $this->client_id; + + return true; + } + + /** + * Verifies that an access token is bound to the client certificate presented to the provider. + * + * The `cnf` claim of a certificate-bound access token holds the SHA-256 thumbprint of the + * certificate the token was issued to. This check is only possible when the access token is + * a JWT the client can read, or when the introspection response carries the claim; a resource + * server performs the authoritative check. + * + * @param Collection $claims Claims of the access token, or an introspection response + * @throws OIDCClientException If the token is bound to a different certificate + * @see https://tools.ietf.org/html/rfc8705#section-3 + */ + public function verifyCertificateBinding(Collection $claims): bool + { + $thumbprint = data_get($claims->all(), 'cnf.x5t#S256'); + if (!is_string($thumbprint) || $thumbprint === '') { + return false; + } + + if ($this->mtls_certificate === null) { + throw new OIDCClientException( + 'The token is certificate-bound but no client certificate is configured' + ); + } + + if (!hash_equals($this->mtls_certificate->getThumbprint(), $thumbprint)) { + throw new OIDCClientException( + 'The token is bound to a different certificate than the configured one' + ); + } + + return true; + } +} diff --git a/src/Traits/Token.php b/src/Traits/Token.php index 8c2110d6..f4a2f4c0 100644 --- a/src/Traits/Token.php +++ b/src/Traits/Token.php @@ -52,13 +52,16 @@ public function refreshToken(#[SensitiveParameter] string $refresh_token, bool $ $client = $this->client(); - // 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']); } - $response = $client->post($this->token_endpoint, $data)->collect(); + $response = $client->post($this->mtlsEndpoint('token', $this->token_endpoint), $data)->collect(); $this->access_token = $response->get('access_token'); $this->refresh_token = $response->get('refresh_token'); @@ -87,10 +90,17 @@ public function introspectToken( $client_id ??= $this->client_id; $client_secret ??= $this->client_secret; - return $this->client() - ->withBasicAuth($client_id, $client_secret) + $client = $this->client(); + // With mutual TLS the certificate authenticates the client, so no secret is sent + if ($this->applyMutualTlsClientAuth($data)) { + $data['client_id'] = $client_id; + } else { + $client = $client->withBasicAuth($client_id, $client_secret); + } + + return $client ->asForm() - ->post($this->introspect_endpoint, $data) + ->post($this->mtlsEndpoint('introspection', $this->introspect_endpoint), $data) ->collect(); } @@ -115,10 +125,20 @@ public function revokeToken( $client_id ??= $this->client_id; $client_secret ??= $this->client_secret; - return $this->client() - ->withBasicAuth($client_id, $client_secret) + $client = $this->client(); + // With mutual TLS the certificate authenticates the client, so no secret is sent + if ($this->applyMutualTlsClientAuth($data)) { + $data['client_id'] = $client_id; + } else { + $client = $client->withBasicAuth($client_id, $client_secret); + } + + // RFC 7009 section 2.1 requires the revocation request to be form encoded; a JSON body + // leaves the provider unable to read client_id and it answers with invalid_client. + return $client + ->asForm() ->acceptJson() - ->post($this->revocation_endpoint, $data) + ->post($this->mtlsEndpoint('revocation', $this->revocation_endpoint), $data) ->collect(); } @@ -174,9 +194,12 @@ private function requestTokens(string $code): Collection 'client_secret' => $this->client_secret ]; - // Consider Basic authentication if provider config is set this way $client = $this->client(); - 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']); } @@ -186,6 +209,6 @@ private function requestTokens(string $code): Collection $data['code_verifier'] = $code_verifier; } - return $client->asForm()->post($this->token_endpoint, $data)->collect(); + return $client->asForm()->post($this->mtlsEndpoint('token', $this->token_endpoint), $data)->collect(); } } diff --git a/tests/Feature/MutualTlsTest.php b/tests/Feature/MutualTlsTest.php new file mode 100644 index 00000000..6d9162a9 --- /dev/null +++ b/tests/Feature/MutualTlsTest.php @@ -0,0 +1,322 @@ +getRequestOptions())->toBe([ + 'cert' => $certificate->certificate_path, + 'ssl_key' => $certificate->private_key_path, + ]); +}); + +test('certificate passphrase is carried into the guzzle options', function () { + $certificate = new MutualTlsCertificate( + certificate_path: certificate('client')->certificate_path, + private_key_path: certificate('client')->private_key_path, + passphrase: 'secret' + ); + + expect($certificate->getRequestOptions())->toBe([ + 'cert' => [$certificate->certificate_path, 'secret'], + 'ssl_key' => [$certificate->private_key_path, 'secret'], + ]); +}); + +test('certificate without a separate private key omits the ssl_key option', function () { + $bundle = tempnam(sys_get_temp_dir(), 'oidc_bundle_'); + file_put_contents($bundle, file_get_contents(certificate('client')->certificate_path) + . file_get_contents(certificate('client')->private_key_path)); + + try { + expect((new MutualTlsCertificate($bundle))->getRequestOptions())->toBe(['cert' => $bundle]); + } finally { + unlink($bundle); + } +}); + +test('certificate thumbprint is the base64url encoded sha-256 of the der body', function () { + $certificate = certificate('client'); + $pem = file_get_contents($certificate->certificate_path); + preg_match('/-----BEGIN CERTIFICATE-----(.+?)-----END CERTIFICATE-----/s', $pem, $matches); + $expected = rtrim(strtr(base64_encode(hash('sha256', base64_decode(preg_replace('/\s+/', '', $matches[1])), true)), '+/', '-_'), '='); + + // Base64url: no padding and no '+' or '/' from standard base64. + expect($certificate->getThumbprint())->toBe($expected) + ->and($certificate->getThumbprint())->not->toContain('=') + ->and($certificate->getThumbprint())->not->toContain('+') + ->and($certificate->getThumbprint())->not->toContain('/'); +}); + +test('certificate thumbprint ignores a private key bundled with the certificate', function () { + $certificate = certificate('client'); + $bundle = tempnam(sys_get_temp_dir(), 'oidc_bundle_'); + file_put_contents($bundle, file_get_contents($certificate->certificate_path) + . file_get_contents($certificate->private_key_path)); + + try { + expect((new MutualTlsCertificate($bundle))->getThumbprint())->toBe($certificate->getThumbprint()); + } finally { + unlink($bundle); + } +}); + +test('certificate thumbprints differ between certificates', function () { + expect(certificate('client')->getThumbprint())->not->toBe(certificate('other')->getThumbprint()); +}); + +test('unreadable certificate and private key are rejected on construction', function () { + expect(fn () => new MutualTlsCertificate('/does/not/exist.crt')) + ->toThrow(OIDCClientException::class, 'does not exist or is not readable'); + + expect(fn () => new MutualTlsCertificate(certificate('client')->certificate_path, '/does/not/exist.key')) + ->toThrow(OIDCClientException::class, 'does not exist or is not readable'); +}); + +test('unparseable certificate is rejected when computing the thumbprint', function () { + $garbage = tempnam(sys_get_temp_dir(), 'oidc_garbage_'); + file_put_contents($garbage, 'not a certificate'); + + try { + expect(fn () => (new MutualTlsCertificate($garbage))->getThumbprint()) + ->toThrow(OIDCClientException::class, 'Unable to parse the client certificate'); + } finally { + unlink($garbage); + } +}); + +test('mutual tls auth methods are recognised', function () { + expect(ClientAuthMethod::tryFrom('tls_client_auth'))->toBe(ClientAuthMethod::TLS_CLIENT_AUTH) + ->and(ClientAuthMethod::tryFrom('self_signed_tls_client_auth'))->toBe(ClientAuthMethod::SELF_SIGNED_TLS_CLIENT_AUTH) + ->and(ClientAuthMethod::TLS_CLIENT_AUTH->isMutualTls())->toBeTrue() + ->and(ClientAuthMethod::SELF_SIGNED_TLS_CLIENT_AUTH->isMutualTls())->toBeTrue() + ->and(ClientAuthMethod::CLIENT_SECRET_BASIC->isMutualTls())->toBeFalse() + ->and(ClientAuthMethod::PRIVATE_KEY_JWT->isMutualTls())->toBeFalse() + ->and(ClientAuthMethod::NONE->isMutualTls())->toBeFalse(); +}); + +test('client uses mutual tls when a certificate and a mutual tls method are configured', function () { + expect(mtlsClient()->usesMutualTlsClientAuth())->toBeTrue(); +}); + +test('client does not use mutual tls without a certificate', function () { + $client = client(token_endpoint_auth_method: ClientAuthMethod::TLS_CLIENT_AUTH); + + expect($client->usesMutualTlsClientAuth())->toBeFalse(); +}); + +test('client does not use mutual tls when the configured method is secret based', function () { + $client = client( + client_secret: 'a-secret', + token_endpoint_auth_method: ClientAuthMethod::CLIENT_SECRET_BASIC, + mtls_certificate: certificate('client') + ); + + expect($client->usesMutualTlsClientAuth())->toBeFalse(); +}); + +test('an advertised mutual tls method is selected when a certificate is configured', function () { + $client = client(mtls_certificate: certificate('client')); + $this->setProperty($client, 'token_endpoint_auth_methods_supported', [ + ClientAuthMethod::CLIENT_SECRET_BASIC, + ClientAuthMethod::TLS_CLIENT_AUTH, + ]); + + expect($this->invokeMethod($client, 'getClientAuthMethod'))->toBe(ClientAuthMethod::TLS_CLIENT_AUTH) + ->and($client->usesMutualTlsClientAuth())->toBeTrue(); +}); + +test('an advertised mutual tls method is ignored when no certificate is configured', function () { + // A provider advertising tls_client_auth must never push a secret based client onto a + // method it cannot possibly use. + $client = client(client_secret: 'a-secret'); + $this->setProperty($client, 'token_endpoint_auth_methods_supported', [ClientAuthMethod::TLS_CLIENT_AUTH]); + + expect($this->invokeMethod($client, 'getClientAuthMethod'))->toBeNull() + ->and($client->usesMutualTlsClientAuth())->toBeFalse(); +}); + +test('no client secret is sent under mutual tls', function () { + $data = ['grant_type' => 'client_credentials', 'client_secret' => 'should-be-removed']; + + expect($this->invokeMethod(mtlsClient(), 'applyMutualTlsClientAuth', [&$data]))->toBeTrue() + ->and($data)->not->toHaveKey('client_secret') + ->and($data['client_id'] ?? null)->toBe('mtls-client'); +}); + +test('a secret based client keeps its client secret', function () { + $client = client(client_secret: 'a-secret', token_endpoint_auth_method: ClientAuthMethod::CLIENT_SECRET_POST); + $data = ['grant_type' => 'client_credentials', 'client_secret' => 'a-secret']; + + expect($this->invokeMethod($client, 'applyMutualTlsClientAuth', [&$data]))->toBeFalse() + ->and($data['client_secret'])->toBe('a-secret'); +}); + +test('mutual tls auth method without a certificate throws a clear error', function () { + $client = client(token_endpoint_auth_method: ClientAuthMethod::TLS_CLIENT_AUTH); + $data = ['grant_type' => 'client_credentials']; + + expect(fn () => $this->invokeMethod($client, 'applyMutualTlsClientAuth', [&$data])) + ->toThrow(OIDCClientException::class, 'requires a client certificate'); +}); + +test('endpoints resolve to the mutual tls aliases advertised by the provider', function () { + $client = mtlsClient(); + $this->setProperty($client, 'mtls_endpoint_aliases', [ + 'token_endpoint' => 'https://mtls.example.com/token', + ]); + + expect($this->invokeMethod($client, 'mtlsEndpoint', ['token', 'https://example.com/token'])) + ->toBe('https://mtls.example.com/token') + // An endpoint without an alias keeps the regular URL. + ->and($this->invokeMethod($client, 'mtlsEndpoint', ['userinfo', 'https://example.com/userinfo'])) + ->toBe('https://example.com/userinfo'); +}); + +test('endpoints are unchanged when no certificate is configured', function () { + $client = client(client_secret: 'a-secret'); + $this->setProperty($client, 'mtls_endpoint_aliases', ['token_endpoint' => 'https://mtls.example.com/token']); + + expect($this->invokeMethod($client, 'mtlsEndpoint', ['token', 'https://example.com/token'])) + ->toBe('https://example.com/token'); +}); + +test('certificate bound access tokens alone route through the aliased endpoint', function () { + // A client may authenticate with a secret and still ask for certificate-bound tokens: the + // certificate has to be presented at the token endpoint, so the alias must be used. + $client = client( + client_secret: 'a-secret', + token_endpoint_auth_method: ClientAuthMethod::CLIENT_SECRET_BASIC, + mtls_certificate: certificate('client'), + tls_client_certificate_bound_access_tokens: true + ); + $this->setProperty($client, 'mtls_endpoint_aliases', ['token_endpoint' => 'https://mtls.example.com/token']); + + expect($this->invokeMethod($client, 'mtlsEndpoint', ['token', 'https://example.com/token'])) + ->toBe('https://mtls.example.com/token') + // The certificate authenticates nothing here, it only binds the token. + ->and($client->usesMutualTlsClientAuth())->toBeFalse(); +}); + +test('a certificate used for neither authentication nor binding does not divert endpoints', function () { + $client = client( + client_secret: 'a-secret', + token_endpoint_auth_method: ClientAuthMethod::CLIENT_SECRET_BASIC, + mtls_certificate: certificate('client') + ); + $this->setProperty($client, 'mtls_endpoint_aliases', ['token_endpoint' => 'https://mtls.example.com/token']); + + expect($this->invokeMethod($client, 'mtlsEndpoint', ['token', 'https://example.com/token'])) + ->toBe('https://example.com/token'); +}); + +test('dynamic registration resolves the aliased registration endpoint', function () { + // register() is where a mutual-TLS client announces itself, so it is the one call that most + // needs the certificate presented. + $client = mtlsClient(); + $this->setProperty($client, 'mtls_endpoint_aliases', [ + 'registration_endpoint' => 'https://mtls.example.com/register', + ]); + + expect($this->invokeMethod($client, 'mtlsEndpoint', ['registration', 'https://example.com/register'])) + ->toBe('https://mtls.example.com/register'); +}); + +test('a token bound to the configured certificate is accepted', function () { + $client = mtlsClient(); + + expect($client->verifyCertificateBinding(collect(['cnf' => ['x5t#S256' => certificate('client')->getThumbprint()]]))) + ->toBeTrue(); +}); + +test('a token bound to another certificate is rejected', function () { + $client = mtlsClient(); + + expect(fn () => $client->verifyCertificateBinding(collect(['cnf' => ['x5t#S256' => certificate('other')->getThumbprint()]]))) + ->toThrow(OIDCClientException::class, 'bound to a different certificate'); +}); + +test('a token with no confirmation claim is not certificate bound', function () { + expect(mtlsClient()->verifyCertificateBinding(collect(['sub' => 'nobody'])))->toBeFalse() + ->and(mtlsClient()->verifyCertificateBinding(collect(['cnf' => ['x5t#S256' => '']])))->toBeFalse(); +}); + +test('a certificate bound token is rejected when no certificate is configured', function () { + $client = client(client_secret: 'a-secret'); + + expect(fn () => $client->verifyCertificateBinding(collect(['cnf' => ['x5t#S256' => certificate('client')->getThumbprint()]]))) + ->toThrow(OIDCClientException::class, 'no client certificate is configured'); +}); + +test('the client certificate is presented on every request', function () { + $certificate = certificate('client'); + $options = requestOptions(mtlsClient()); + + expect($options['cert'])->toBe($certificate->certificate_path) + ->and($options['ssl_key'])->toBe($certificate->private_key_path); +}); + +test('no certificate options are set when no certificate is configured', function () { + expect(requestOptions(client(client_secret: 'a-secret'))) + ->not->toHaveKey('cert') + ->not->toHaveKey('ssl_key'); +}); + +test('a ca bundle is always used to verify the provider, even when verify_ssl is false', function () { + // Earlier versions documented `verify_ssl: false` as the way to make cert_path take effect, + // so this combination must keep verifying rather than trusting any certificate. + $bundle = certificate('client')->certificate_path; + + expect(requestOptions(client(cert_path: $bundle))['verify'])->toBe($bundle) + ->and(requestOptions(client(cert_path: $bundle, verify_ssl: false))['verify'])->toBe($bundle); +}); + +test('verification stays on by default and is only disabled without a bundle', function () { + expect(requestOptions(client())['verify'])->toBeTrue() + ->and(requestOptions(client(verify_ssl: false))['verify'])->toBeFalse(); +}); + +test('no configuration verifies less than it did before mutual tls support', function () { + $bundle = certificate('client')->certificate_path; + + foreach ([[true, null], [true, $bundle], [false, null], [false, $bundle]] as [$verify_ssl, $cert_path]) { + // The behaviour of the original expression, before RFC 8705 support was added. + $before = ($verify_ssl ?: $cert_path) ?? false; + $now = requestOptions(client(cert_path: $cert_path, verify_ssl: $verify_ssl))['verify']; + + // Verification may be strengthened (false -> bundle/true) but never weakened. + if ($before !== false) { + expect($now)->not->toBeFalse(); + } + if (is_string($before)) { + expect($now)->toBe($before); + } + } +}); diff --git a/tests/Pest.php b/tests/Pest.php index 4d8f9f61..fe6caa22 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -14,6 +14,9 @@ * limitations under the License. */ +use Maicol07\OpenIDConnect\Client; +use Maicol07\OpenIDConnect\ClientAuthMethod; +use Maicol07\OpenIDConnect\MutualTlsCertificate; use Maicol07\OpenIDConnect\Tests\TestCase; /* @@ -55,7 +58,83 @@ | */ -function something() +/** + * A self-signed certificate and its private key, generated once per test run and reused + * across tests. Keeps the mutual-TLS tests free of both fixture files and a live provider. + * + * @param string $name Identifies the certificate; different names get different key pairs + */ +function certificate(string $name = 'client'): MutualTlsCertificate +{ + static $certificates = []; + + if (!isset($certificates[$name])) { + $key = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); + if ($key === false) { + throw new RuntimeException('Unable to generate a test key pair: ' . openssl_error_string()); + } + + $csr = openssl_csr_new(['commonName' => $name], $key, ['digest_alg' => 'sha256']); + if ($csr === false) { + throw new RuntimeException('Unable to generate a test CSR: ' . openssl_error_string()); + } + + $signed = openssl_csr_sign($csr, null, $key, 1, ['digest_alg' => 'sha256']); + if ($signed === false) { + throw new RuntimeException('Unable to sign the test certificate: ' . openssl_error_string()); + } + + openssl_x509_export($signed, $certificate); + openssl_pkey_export($key, $private_key); + + $directory = sys_get_temp_dir() . '/oidc-client-php-tests'; + if (!is_dir($directory)) { + mkdir($directory, 0700, true); + } + + file_put_contents("$directory/$name.crt", $certificate); + file_put_contents("$directory/$name.key", $private_key); + chmod("$directory/$name.key", 0600); + + $certificates[$name] = new MutualTlsCertificate("$directory/$name.crt", "$directory/$name.key"); + } + + return $certificates[$name]; +} + +/** + * A client that talks to no provider: without a provider_url the constructor skips auto + * discovery, so only the configuration passed here decides how the client behaves. + * + * @param array $parameters Constructor parameters to override + */ +function client(mixed ...$parameters): Client +{ + return new Client(...[ + 'client_id' => 'mtls-client', + 'redirect_uri' => 'https://example.com/callback', + ...$parameters, + ]); +} + +/** A client configured for PKI mutual-TLS client authentication (RFC 8705 section 2.1). */ +function mtlsClient(): Client { - // .. + return client( + token_endpoint_auth_method: ClientAuthMethod::TLS_CLIENT_AUTH, + mtls_certificate: certificate('client') + ); +} + +/** + * The Guzzle request options the client would send, including the TLS `verify` setting and + * any client certificate. + * + * @return array + */ +function requestOptions(Client $client): array +{ + $request = (new ReflectionMethod($client, 'client'))->invoke($client); + + return (new ReflectionProperty($request, 'options'))->getValue($request); } diff --git a/tests/TestCase.php b/tests/TestCase.php index 3dbe134a..89e43f52 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -118,4 +118,17 @@ public function getProperty(object $object, string $propertyName): mixed { return (new ReflectionClass(get_class($object)))->getProperty($propertyName)->getValue($object); } + + /** + * Set a protected/private property of a class. + * + * @param object $object An instantiated object that we will set the property on. + * @param string $propertyName Property name to set + * @param mixed $value Value to set the property to + * @throws ReflectionException If the property doesn't exist. + */ + public function setProperty(object $object, string $propertyName, mixed $value): void + { + (new ReflectionClass(get_class($object)))->getProperty($propertyName)->setValue($object, $value); + } }