From cb283e214eaadfc2ceacd6eb7dfac6efb7bee573 Mon Sep 17 00:00:00 2001 From: Corey Koval Date: Sat, 1 Aug 2026 20:07:21 -0400 Subject: [PATCH 1/8] =?UTF-8?q?feat(auth):=20=F0=9F=94=90=20Add=20RFC=2087?= =?UTF-8?q?05=20mutual-TLS=20client=20authentication?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds support for OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens, letting a client authenticate with a certificate presented during the TLS handshake instead of a shared secret. - Added `TLS_CLIENT_AUTH` and `SELF_SIGNED_TLS_CLIENT_AUTH` to `ClientAuthMethod`, with an `isMutualTls()` helper. - Added the `MutualTlsCertificate` value object, which carries the certificate, private key and optional passphrase, exposes the Guzzle request options that present it during the handshake, and computes the base64url SHA-256 thumbprint used in the `cnf` / `x5t#S256` claim. - Added the `MutualTls` trait, which resolves the mutual-TLS endpoint aliases, suppresses the client secret when the certificate authenticates the client, and verifies that an access token is bound to the configured certificate. - Suppressed the client secret across every token endpoint request: authorization code, refresh, token exchange, introspection and revocation. The latter two previously hardcoded Basic authentication. - Parsed `mtls_endpoint_aliases` and `tls_client_certificate_bound_access_tokens` during auto discovery, and used the aliased endpoints automatically. - Registered the mutual-TLS authentication method during dynamic registration, and stopped requiring a client secret that mutual-TLS clients do not have. - Selected an announced mutual-TLS method only when a certificate is configured, so existing secret-based clients keep their current behaviour. - Added a self-contained Docker test stack (Keycloak, certificate generation, `docker/run-tests.sh`) covering the happy paths plus the negative cases: a request without a certificate, a certificate with a mismatched subject DN, and a certificate-bound token replayed without its certificate. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UGmjsaiMbu1LxgCzWB9Djx --- README.md | 80 ++++++++ docker/Dockerfile | 18 ++ docker/certs/.gitignore | 3 + docker/docker-compose.yml | 53 ++++++ docker/realm-export.json | 78 ++++++++ docker/run-tests.sh | 41 ++++ docker/scripts/generate-certs.sh | 94 +++++++++ docker/tests/rfc8705_test.php | 294 +++++++++++++++++++++++++++++ src/Client.php | 24 ++- src/ClientAuthMethod.php | 25 +++ src/MutualTlsCertificate.php | 109 +++++++++++ src/Traits/AutoDiscovery.php | 9 + src/Traits/DynamicRegistration.php | 12 +- src/Traits/MutualTls.php | 155 +++++++++++++++ src/Traits/Token.php | 44 +++-- 15 files changed, 1021 insertions(+), 18 deletions(-) create mode 100644 docker/Dockerfile create mode 100644 docker/certs/.gitignore create mode 100644 docker/docker-compose.yml create mode 100644 docker/realm-export.json create mode 100755 docker/run-tests.sh create mode 100755 docker/scripts/generate-certs.sh create mode 100644 docker/tests/rfc8705_test.php create mode 100644 src/MutualTlsCertificate.php create mode 100644 src/Traits/MutualTls.php diff --git a/README.md b/README.md index 8c2983d1..b27ab37f 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 @@ -182,6 +183,66 @@ $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. + +### 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 @@ -217,6 +278,25 @@ 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. +### Mutual TLS (RFC 8705) +The mutual-TLS support has a self-contained test stack that needs nothing but Docker. It generates a +CA and client certificates, starts a Keycloak configured for mutual TLS and certificate-bound access +tokens, and runs the tests against it: + +```bash +./docker/run-tests.sh # run the tests and tear the stack down +./docker/run-tests.sh --keep # leave Keycloak running on https://localhost:8443 (admin/admin) +``` + +Besides the happy paths, the suite covers the cases that make the spec worth implementing: a token +request without a certificate is rejected, a certificate with the wrong subject DN is rejected, and a +certificate-bound access token is refused by the userinfo endpoint when the certificate is not +presented. Generated certificates stay out of git. + +Two notes on Keycloak as a test provider: it implements the PKI variant (`tls_client_auth`) and does +not advertise `self_signed_tls_client_auth`, and its trust store must be a Java trust store built +with `keytool` — a PKCS#12 file created by `openssl pkcs12 -export -nokeys` makes it abort the TLS +handshake. ### Todo - Dynamic registration does not support registration auth tokens and endpoints diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 00000000..62ee1e64 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,18 @@ +# PHP 8.4 — composer.lock pins Symfony 8 / Illuminate 13, which require PHP >= 8.4. +FROM php:8.4-cli + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git unzip libzip-dev libicu-dev libxml2-dev openssl ca-certificates \ + && docker-php-ext-install -j"$(nproc)" zip intl dom xml \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +WORKDIR /app + +# Install dependencies first so they are cached independently of the source. +COPY composer.json composer.lock ./ +RUN composer install --no-interaction --no-progress --no-scripts --no-autoloader + +COPY src ./src +RUN composer dump-autoload --optimize diff --git a/docker/certs/.gitignore b/docker/certs/.gitignore new file mode 100644 index 00000000..e0199e95 --- /dev/null +++ b/docker/certs/.gitignore @@ -0,0 +1,3 @@ +# Test certificates are generated by docker/scripts/generate-certs.sh — never commit keys. +* +!.gitignore diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 00000000..a1f1eb1d --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,53 @@ +# Test stack for RFC 8705 (Mutual-TLS Client Authentication and Certificate-Bound Access Tokens). +# +# docker compose -f docker/docker-compose.yml run --rm tests +# +# Certificates are generated by docker/scripts/generate-certs.sh before starting the stack. +services: + keycloak: + image: quay.io/keycloak/keycloak:26.0 + command: + - start-dev + - --import-realm + - --https-certificate-file=/opt/keycloak/certs/keycloak.crt + - --https-certificate-key-file=/opt/keycloak/certs/keycloak.key + # Request — not require — a client certificate, so the discovery document and the + # regular endpoints stay reachable without one. + - --https-client-auth=request + - --https-trust-store-file=/opt/keycloak/certs/truststore.jks + - --https-trust-store-password=password + - --features=token-exchange + environment: + KC_BOOTSTRAP_ADMIN_USERNAME: admin + KC_BOOTSTRAP_ADMIN_PASSWORD: admin + KC_HTTP_ENABLED: "true" + KC_HOSTNAME_STRICT: "false" + volumes: + - ./certs:/opt/keycloak/certs:ro + - ./realm-export.json:/opt/keycloak/data/import/realm-export.json:ro + ports: + - "8080:8080" + - "8443:8443" + healthcheck: + # Keycloak 26 has no curl/wget in the image; use the JVM-less bash /dev/tcp probe. + test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/8443 || exit 1"] + interval: 5s + timeout: 5s + retries: 40 + start_period: 20s + + tests: + build: + context: .. + dockerfile: docker/Dockerfile + depends_on: + keycloak: + condition: service_healthy + environment: + KEYCLOAK_URL: https://keycloak:8443 + REALM: mtls + CERT_DIR: /app/docker/certs + volumes: + - ../src:/app/src:ro + - ../docker:/app/docker:ro + command: ["php", "/app/docker/tests/rfc8705_test.php"] diff --git a/docker/realm-export.json b/docker/realm-export.json new file mode 100644 index 00000000..b221ca40 --- /dev/null +++ b/docker/realm-export.json @@ -0,0 +1,78 @@ +{ + "realm": "mtls", + "enabled": true, + "sslRequired": "none", + "registrationAllowed": false, + "users": [ + { + "username": "testuser", + "enabled": true, + "emailVerified": true, + "email": "testuser@example.com", + "firstName": "Test", + "lastName": "User", + "credentials": [ + { + "type": "password", + "value": "password", + "temporary": false + } + ], + "realmRoles": ["default-roles-mtls"] + } + ], + "clients": [ + { + "clientId": "mtls-client", + "name": "RFC 8705 PKI mutual-TLS client", + "enabled": true, + "protocol": "openid-connect", + "publicClient": false, + "bearerOnly": false, + "serviceAccountsEnabled": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true, + "redirectUris": ["*"], + "webOrigins": ["*"], + "clientAuthenticatorType": "client-x509", + "attributes": { + "x509.subjectdn": "CN=oidc-client-php", + "x509.allow.regex.pattern.comparison": "false", + "tls.client.certificate.bound.access.tokens": "true" + } + }, + { + "clientId": "mtls-client-self-signed", + "name": "RFC 8705 self-signed mutual-TLS client", + "enabled": true, + "protocol": "openid-connect", + "publicClient": false, + "bearerOnly": false, + "serviceAccountsEnabled": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true, + "redirectUris": ["*"], + "webOrigins": ["*"], + "clientAuthenticatorType": "client-x509", + "attributes": { + "x509.subjectdn": "CN=oidc-client-php-self-signed", + "x509.allow.regex.pattern.comparison": "false", + "tls.client.certificate.bound.access.tokens": "true" + } + }, + { + "clientId": "secret-client", + "name": "Client secret baseline (no mutual TLS)", + "enabled": true, + "protocol": "openid-connect", + "publicClient": false, + "bearerOnly": false, + "secret": "secret-client-password", + "serviceAccountsEnabled": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true, + "redirectUris": ["*"], + "webOrigins": ["*"] + } + ] +} diff --git a/docker/run-tests.sh b/docker/run-tests.sh new file mode 100755 index 00000000..dd5cf780 --- /dev/null +++ b/docker/run-tests.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# +# Runs the RFC 8705 test stack end to end: generates certificates, starts Keycloak, +# runs the tests against it and tears the stack down again. +# +# ./docker/run-tests.sh run the tests +# ./docker/run-tests.sh --keep leave Keycloak running afterwards +# +set -euo pipefail + +cd "$(dirname "$0")/.." +COMPOSE="docker compose -f docker/docker-compose.yml" +KEEP=0 +[ "${1:-}" = "--keep" ] && KEEP=1 + +cleanup() { + if [ "$KEEP" -eq 0 ]; then + echo "==> Tearing down" + $COMPOSE down -v >/dev/null 2>&1 || true + else + echo "==> Keeping Keycloak running (https://localhost:8443, admin/admin)" + fi +} +trap cleanup EXIT + +echo "==> Generating certificates" +./docker/scripts/generate-certs.sh + +echo "==> Starting Keycloak" +$COMPOSE up -d keycloak + +echo "==> Waiting for Keycloak to become healthy" +for _ in $(seq 1 60); do + status="$(docker inspect -f '{{.State.Health.Status}}' docker-keycloak-1 2>/dev/null || echo starting)" + [ "$status" = "healthy" ] && break + sleep 5 +done +[ "${status:-}" = "healthy" ] || { echo "Keycloak did not become healthy"; $COMPOSE logs keycloak | tail -40; exit 1; } + +echo "==> Running RFC 8705 tests" +$COMPOSE run --rm tests diff --git a/docker/scripts/generate-certs.sh b/docker/scripts/generate-certs.sh new file mode 100755 index 00000000..e4e04e98 --- /dev/null +++ b/docker/scripts/generate-certs.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env sh +# +# Generates the PKI used by the RFC 8705 test stack: +# +# ca.crt / ca.key Certificate authority, trusted by Keycloak for client certs +# keycloak.crt/.key Keycloak server certificate (CN=keycloak) +# client.crt/.key Client certificate signed by the CA, for tls_client_auth +# client-self-signed.crt Self-signed client certificate, for self_signed_tls_client_auth +# other-client.crt/.key A second CA-signed certificate, to prove binding is enforced +# +set -eu + +CERT_DIR="$(CDPATH='' cd -- "$(dirname -- "$0")/../certs" && pwd)" +cd "$CERT_DIR" + +# The subject DN below must match the client's "Subject DN" in Keycloak (see realm-export.json). +CLIENT_SUBJECT="/CN=oidc-client-php" + +if [ -f client.crt ] && [ "${FORCE:-0}" != "1" ]; then + echo "Certificates already present in $CERT_DIR (set FORCE=1 to regenerate)" + exit 0 +fi + +echo "Generating certificates in $CERT_DIR" + +# --- Certificate authority ------------------------------------------------- +openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \ + -keyout ca.key -out ca.crt \ + -subj "/CN=OIDC Test CA" 2>/dev/null + +# --- Keycloak server certificate ------------------------------------------- +openssl req -newkey rsa:2048 -nodes \ + -keyout keycloak.key -out keycloak.csr \ + -subj "/CN=keycloak" 2>/dev/null + +# SANs so the cert is valid both inside the compose network and from the host +cat > keycloak.ext <<'EOF' +subjectAltName = DNS:keycloak, DNS:localhost, IP:127.0.0.1 +extendedKeyUsage = serverAuth +EOF + +openssl x509 -req -in keycloak.csr -CA ca.crt -CAkey ca.key -CAcreateserial \ + -out keycloak.crt -days 3650 -extfile keycloak.ext 2>/dev/null + +# --- CA-signed client certificate (tls_client_auth) ------------------------ +openssl req -newkey rsa:2048 -nodes \ + -keyout client.key -out client.csr \ + -subj "$CLIENT_SUBJECT" 2>/dev/null + +cat > client.ext <<'EOF' +extendedKeyUsage = clientAuth +EOF + +openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial \ + -out client.crt -days 3650 -extfile client.ext 2>/dev/null + +# --- Self-signed client certificate (self_signed_tls_client_auth) ---------- +openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \ + -keyout client-self-signed.key -out client-self-signed.crt \ + -subj "/CN=oidc-client-php-self-signed" 2>/dev/null + +# --- A different CA-signed client certificate ------------------------------ +# Used to prove that a token bound to `client.crt` is rejected for this one. +openssl req -newkey rsa:2048 -nodes \ + -keyout other-client.key -out other-client.csr \ + -subj "/CN=other-client" 2>/dev/null + +openssl x509 -req -in other-client.csr -CA ca.crt -CAkey ca.key -CAcreateserial \ + -out other-client.crt -days 3650 -extfile client.ext 2>/dev/null + +# Keycloak needs a truststore holding the CA to validate presented client certificates. +# It must be a Java trust store with a "trustedCertEntry" — a PKCS#12 file built by +# `openssl pkcs12 -export -nokeys` is a key store and makes Keycloak abort the handshake. +# +# The self-signed client certificate is added as well: with self_signed_tls_client_auth the +# certificate has no issuing CA, so the TLS layer can only accept it if it is trusted directly. +rm -f truststore.jks +docker run --rm -v "$CERT_DIR:/c" -w /c eclipse-temurin:21-jdk sh -c ' + keytool -importcert -noprompt -trustcacerts -alias ca \ + -file ca.crt -keystore truststore.jks -storepass password && + keytool -importcert -noprompt -trustcacerts -alias self-signed-client \ + -file client-self-signed.crt -keystore truststore.jks -storepass password +' >/dev/null 2>&1 + +rm -f ./*.csr ./*.ext ./*.srl +chmod 644 ./*.key ./*.crt +chmod 644 truststore.jks 2>/dev/null || true + +echo "Client certificate SHA-256 thumbprint (base64url), as it appears in cnf/x5t#S256:" +openssl x509 -in client.crt -outform DER 2>/dev/null \ + | openssl dgst -sha256 -binary \ + | openssl base64 \ + | tr '+/' '-_' | tr -d '=\n' +echo "" diff --git a/docker/tests/rfc8705_test.php b/docker/tests/rfc8705_test.php new file mode 100644 index 00000000..9117519c --- /dev/null +++ b/docker/tests/rfc8705_test.php @@ -0,0 +1,294 @@ +getMessage() . "\n"; + $failed++; + } +} + +/** Decodes a JWT payload without verifying it (the provider is trusted in this test). */ +function jwtClaims(string $jwt): array +{ + $parts = explode('.', $jwt); + return json_decode(base64_decode(strtr($parts[1], '-_', '+/')), true, 512, JSON_THROW_ON_ERROR); +} + +$clientCert = new MutualTlsCertificate("$certDir/client.crt", "$certDir/client.key"); +$selfSignedCert = new MutualTlsCertificate("$certDir/client-self-signed.crt", "$certDir/client-self-signed.key"); +$otherCert = new MutualTlsCertificate("$certDir/other-client.crt", "$certDir/other-client.key"); + +// The stack uses a private CA, so trust it rather than disabling verification. +$caPath = "$certDir/ca.crt"; + +echo "\n=== RFC 8705 against $providerUrl ===\n\n"; + +echo "-- Discovery (RFC 8705 section 5) --\n"; + +$client = new Client( + client_id: 'mtls-client', + provider_url: $providerUrl, + redirect_uri: 'https://example.com/callback', + token_endpoint_auth_method: ClientAuthMethod::TLS_CLIENT_AUTH, + mtls_certificate: $clientCert, + cert_path: $caPath, +); + +check('discovery advertises tls_client_auth', function () use ($client): bool { + return in_array(ClientAuthMethod::TLS_CLIENT_AUTH, $client->token_endpoint_auth_methods_supported, true); +}); + +check('self_signed_tls_client_auth is parsed from discovery metadata', function (): bool { + // Keycloak's client-x509 authenticator only implements the PKI variant, so it does not + // advertise this method. Assert the enum parses the metadata value instead of asserting + // a provider capability that this particular provider does not have. + return ClientAuthMethod::tryFrom('self_signed_tls_client_auth') === ClientAuthMethod::SELF_SIGNED_TLS_CLIENT_AUTH + && ClientAuthMethod::SELF_SIGNED_TLS_CLIENT_AUTH->isMutualTls(); +}); + +check('tls_client_certificate_bound_access_tokens parsed', function () use ($client): bool { + return $client->tls_client_certificate_bound_access_tokens === true; +}); + +check('mtls_endpoint_aliases parsed and applied to the token endpoint', function () use ($client) { + $aliases = (new ReflectionProperty($client, 'mtls_endpoint_aliases'))->getValue($client); + if (empty($aliases)) { + // Keycloak only emits aliases when configured to; not fatal, the regular endpoint is used. + return 'no aliases advertised, falling back to the regular endpoint'; + } + $resolved = (new ReflectionMethod($client, 'mtlsEndpoint')) + ->invoke($client, 'token', $client->token_endpoint); + return $resolved === ($aliases['token_endpoint'] ?? $client->token_endpoint) + ? 'alias applied' + : false; +}); + +check('usesMutualTlsClientAuth() is true', fn (): bool => $client->usesMutualTlsClientAuth() === true); + +echo "\n-- tls_client_auth: PKI mutual-TLS client authentication (section 2.1) --\n"; + +/** + * Requests a token with the client_credentials grant, which exercises exactly the same + * client-authentication path as the authorization_code grant without needing a browser. + */ +$requestToken = static function (Client $c, MutualTlsCertificate $cert, ?string $ca) use ($providerUrl): array { + $data = ['grant_type' => 'client_credentials', 'client_id' => $c->client_id, 'scope' => 'openid']; + // Route through the client's own applyMutualTlsClientAuth so the code under test decides + // what is sent, then post with the same options the library would use. + (new ReflectionMethod($c, 'applyMutualTlsClientAuth'))->invokeArgs($c, [&$data]); + + $response = (new Factory())->withOptions([ + 'verify' => $ca ?? false, + ...$cert->getRequestOptions(), + ])->asForm()->post("$providerUrl/protocol/openid-connect/token", $data); + + return [$response->status(), $response->json() ?? []]; +}; + +$boundAccessToken = null; + +check('token request succeeds with the client certificate and no client_secret', function () use ($requestToken, $client, $clientCert, $caPath, &$boundAccessToken) { + [$status, $body] = $requestToken($client, $clientCert, $caPath); + if ($status !== 200) { + echo ' response: ' . json_encode($body) . "\n"; + return false; + } + $boundAccessToken = $body['access_token'] ?? null; + return is_string($boundAccessToken) ? 'got an access token' : false; +}); + +check('no client_secret is sent under mutual TLS', function () use ($client): bool { + $data = ['grant_type' => 'client_credentials', 'client_id' => 'mtls-client', 'client_secret' => 'should-be-removed']; + (new ReflectionMethod($client, 'applyMutualTlsClientAuth'))->invokeArgs($client, [&$data]); + return !array_key_exists('client_secret', $data) && $data['client_id'] === 'mtls-client'; +}); + +check('token request FAILS without the client certificate', function () use ($providerUrl, $caPath) { + $response = (new Factory())->withOptions(['verify' => $caPath])->asForm()->post( + "$providerUrl/protocol/openid-connect/token", + ['grant_type' => 'client_credentials', 'client_id' => 'mtls-client', 'scope' => 'openid'] + ); + // The provider must reject a client that presents no certificate. + return $response->status() !== 200 ? 'rejected with HTTP ' . $response->status() : false; +}); + +check('token request FAILS with a certificate whose subject DN does not match', function () use ($requestToken, $client, $otherCert, $caPath) { + [$status, $body] = $requestToken($client, $otherCert, $caPath); + return $status !== 200 ? 'rejected with HTTP ' . $status : false; +}); + +echo "\n-- Certificate-bound access tokens (section 3) --\n"; + +check('access token carries the cnf/x5t#S256 confirmation claim', function () use (&$boundAccessToken) { + if (!is_string($boundAccessToken)) { + return false; + } + $claims = jwtClaims($boundAccessToken); + return isset($claims['cnf']['x5t#S256']) + ? 'x5t#S256=' . $claims['cnf']['x5t#S256'] + : false; +}); + +check('cnf/x5t#S256 equals the thumbprint computed by MutualTlsCertificate', function () use (&$boundAccessToken, $clientCert) { + $claims = jwtClaims($boundAccessToken); + return hash_equals($clientCert->getThumbprint(), $claims['cnf']['x5t#S256']) + ? $clientCert->getThumbprint() + : false; +}); + +check('verifyCertificateBinding() accepts a token bound to our certificate', function () use ($client, &$boundAccessToken): bool { + return $client->verifyCertificateBinding(collect(jwtClaims($boundAccessToken))) === true; +}); + +check('verifyCertificateBinding() REJECTS a token bound to another certificate', function () use ($providerUrl, $otherCert, $caPath, &$boundAccessToken) { + $wrongClient = new Client( + client_id: 'mtls-client', + provider_url: $providerUrl, + redirect_uri: 'https://example.com/callback', + token_endpoint_auth_method: ClientAuthMethod::TLS_CLIENT_AUTH, + mtls_certificate: $otherCert, + cert_path: $caPath, + ); + try { + $wrongClient->verifyCertificateBinding(collect(jwtClaims($boundAccessToken))); + return false; // should have thrown + } catch (OIDCClientException) { + return 'threw OIDCClientException as expected'; + } +}); + +check('verifyCertificateBinding() returns false for a token with no cnf claim', function () use ($client): bool { + return $client->verifyCertificateBinding(collect(['sub' => 'nobody'])) === false; +}); + +check('userinfo accepts the certificate-bound token when the certificate is presented', function () use ($providerUrl, $clientCert, $caPath, &$boundAccessToken) { + $response = (new Factory())->withOptions([ + 'verify' => $caPath, + ...$clientCert->getRequestOptions(), + ])->withToken($boundAccessToken)->acceptJson()->get("$providerUrl/protocol/openid-connect/userinfo"); + return $response->ok() ? 'HTTP 200' : 'unexpected HTTP ' . $response->status(); +}); + +check('userinfo REJECTS the certificate-bound token when NO certificate is presented', function () use ($providerUrl, $caPath, &$boundAccessToken) { + $response = (new Factory())->withOptions(['verify' => $caPath]) + ->withToken($boundAccessToken)->acceptJson() + ->get("$providerUrl/protocol/openid-connect/userinfo"); + // This is the whole point of certificate binding: a stolen token is useless without the key. + return !$response->ok() ? 'rejected with HTTP ' . $response->status() : false; +}); + +echo "\n-- self_signed_tls_client_auth (section 2.2) --\n"; + +$selfSignedClient = new Client( + client_id: 'mtls-client-self-signed', + provider_url: $providerUrl, + redirect_uri: 'https://example.com/callback', + token_endpoint_auth_method: ClientAuthMethod::SELF_SIGNED_TLS_CLIENT_AUTH, + mtls_certificate: $selfSignedCert, + cert_path: $caPath, +); + +check('self-signed certificate is presented, authenticates and yields a bound token', function () use ($requestToken, $selfSignedClient, $selfSignedCert, $caPath) { + // The certificate is in Keycloak's trust store, so the handshake succeeds; Keycloak then + // authenticates the client by matching the certificate subject DN. + [$status, $body] = $requestToken($selfSignedClient, $selfSignedCert, $caPath); + if ($status !== 200) { + echo ' response: ' . json_encode($body) . "\n"; + return false; + } + $claims = jwtClaims($body['access_token']); + $thumb = $claims['cnf']['x5t#S256'] ?? null; + return $thumb !== null && hash_equals($selfSignedCert->getThumbprint(), $thumb) + ? 'bound to the self-signed certificate' + : false; +}); + +echo "\n-- Regressions: secret-based clients still work --\n"; + +check('client_secret_basic client is unaffected by the RFC 8705 changes', function () use ($providerUrl, $caPath) { + $secretClient = new Client( + client_id: 'secret-client', + client_secret: 'secret-client-password', + provider_url: $providerUrl, + redirect_uri: 'https://example.com/callback', + cert_path: $caPath, + ); + // No certificate configured, so mutual TLS must stay entirely out of the way. + if ($secretClient->usesMutualTlsClientAuth()) { + return false; + } + $data = ['grant_type' => 'client_credentials', 'client_id' => 'secret-client', 'client_secret' => 'secret-client-password']; + $applied = (new ReflectionMethod($secretClient, 'applyMutualTlsClientAuth'))->invokeArgs($secretClient, [&$data]); + if ($applied || !isset($data['client_secret'])) { + return false; // the secret must be left alone + } + $response = (new Factory())->withOptions(['verify' => $caPath])->asForm() + ->post("$providerUrl/protocol/openid-connect/token", $data); + return $response->ok() ? 'still authenticates with its secret' : false; +}); + +check('endpoints are unchanged when no certificate is configured', function () use ($providerUrl, $caPath): bool { + $plain = new Client( + client_id: 'secret-client', + client_secret: 'secret-client-password', + provider_url: $providerUrl, + redirect_uri: 'https://example.com/callback', + cert_path: $caPath, + ); + $resolved = (new ReflectionMethod($plain, 'mtlsEndpoint'))->invoke($plain, 'token', $plain->token_endpoint); + return $resolved === $plain->token_endpoint; +}); + +check('mutual-TLS auth method without a certificate throws a clear error', function () use ($providerUrl, $caPath) { + $noCert = new Client( + client_id: 'mtls-client', + provider_url: $providerUrl, + redirect_uri: 'https://example.com/callback', + token_endpoint_auth_method: ClientAuthMethod::TLS_CLIENT_AUTH, + cert_path: $caPath, + ); + $data = ['grant_type' => 'client_credentials']; + try { + (new ReflectionMethod($noCert, 'applyMutualTlsClientAuth'))->invokeArgs($noCert, [&$data]); + return false; + } catch (OIDCClientException $e) { + return str_contains($e->getMessage(), 'requires a client certificate') ? 'clear error' : false; + } +}); + +echo "\n=== $passed passed, $failed failed ===\n\n"; +exit($failed === 0 ? 0 : 1); diff --git a/src/Client.php b/src/Client.php index 60ae39b3..d22d088d 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,6 +73,9 @@ 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. @@ -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,10 @@ private function client(): PendingRequest ->withOptions([ 'connect_timeout' => $this->timeout, 'proxy' => $this->http_proxy, - 'verify' => ($this->verify_ssl ?: $this->cert_path) ?? false + 'verify' => ($this->verify_ssl ?: $this->cert_path) ?? false, + // 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..4366ef9b --- /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(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..4dc922de 100644 --- a/src/Traits/DynamicRegistration.php +++ b/src/Traits/DynamicRegistration.php @@ -32,6 +32,15 @@ public function register(?array $params = null): void ->put('redirect_uris', [$this->redirect_uri]) ->put('client_name', $this->client_name); + // 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->registration_endpoint, $data->all())->collect(); $error = $response->get('error_description'); @@ -46,7 +55,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..7c7e5b93 --- /dev/null +++ b/src/Traits/MutualTls.php @@ -0,0 +1,155 @@ + + * @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; + } + + return collect($this->token_endpoint_auth_methods_supported) + ->first(static fn (ClientAuthMethod $method): bool => $method->isMutualTls()); + } + + /** + * 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->mtls_certificate === null) { + return $endpoint; + } + + return $this->mtls_endpoint_aliases["{$name}_endpoint"] ?? $endpoint; + } + + /** + * 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..dad02558 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->usesMutualTlsClientAuth()) { + $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,17 @@ 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->usesMutualTlsClientAuth()) { + $data['client_id'] = $client_id; + } else { + $client = $client->withBasicAuth($client_id, $client_secret); + } + + return $client ->acceptJson() - ->post($this->revocation_endpoint, $data) + ->post($this->mtlsEndpoint('revocation', $this->revocation_endpoint), $data) ->collect(); } @@ -174,9 +191,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 +206,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(); } } From 107fa9951d00e57df58f3b1a90dba2efabef3026 Mon Sep 17 00:00:00 2001 From: Corey Koval Date: Sat, 1 Aug 2026 20:09:42 -0400 Subject: [PATCH 2/8] =?UTF-8?q?fix(security):=20=F0=9F=94=92=20Always=20ho?= =?UTF-8?q?nour=20cert=5Fpath=20when=20verifying=20the=20provider=20certif?= =?UTF-8?q?icate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cert_path` was only applied when `verify_ssl` was `false`, because `($verify_ssl ?: $cert_path) ?? false` short-circuits to `true` whenever `verify_ssl` is true. A CA bundle was therefore silently ignored in the default configuration, and the only way to make it take effect was to also disable verification — which is exactly what the README documented. The bundle is now always used when one is given, so a private CA can be trusted without turning verification off: | verify_ssl | cert_path | before | after | |------------|-----------|------------|------------| | true | null | true | true | | true | bundle | true | bundle | | false | null | false | false | | false | bundle | bundle | bundle | - Replaced the expression with `$this->cert_path ?? $this->verify_ssl`, so verification is only disabled when it is turned off *and* no bundle is given. No configuration verifies less than it did before. - Updated Example 3, the Development Environments section and the constructor PHPDoc, which all documented `verify_ssl: false` as the way to enable `cert_path`. - Added regression tests covering the four combinations, including one asserting that no configuration verifies less than the original expression did. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UGmjsaiMbu1LxgCzWB9Djx --- README.md | 35 +++++++++++++++++++----- docker/tests/rfc8705_test.php | 51 +++++++++++++++++++++++++++++++++++ src/Client.php | 10 ++++--- 3 files changed, 86 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index b27ab37f..2f765a4c 100644 --- a/README.md +++ b/README.md @@ -75,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`. @@ -90,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 @@ -215,6 +222,16 @@ automatically. If `token_endpoint_auth_method` is omitted, a mutual-TLS method a 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', +); +``` + ### 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 @@ -245,8 +262,12 @@ 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; diff --git a/docker/tests/rfc8705_test.php b/docker/tests/rfc8705_test.php index 9117519c..47bc28c0 100644 --- a/docker/tests/rfc8705_test.php +++ b/docker/tests/rfc8705_test.php @@ -237,6 +237,57 @@ function jwtClaims(string $jwt): array : false; }); +echo "\n-- TLS verification: cert_path must never silently stop verifying --\n"; + +/** The Guzzle `verify` option the client would use for the given configuration. */ +$verifyOption = static function (?string $certPath, bool $verifySsl) use ($providerUrl): mixed { + $c = new Client( + client_id: 'secret-client', + client_secret: 'secret-client-password', + // No provider_url: auto discovery would need a working connection, and only the + // resulting request options matter here. + redirect_uri: 'https://example.com/callback', + cert_path: $certPath, + verify_ssl: $verifySsl, + ); + $request = (new ReflectionMethod($c, 'client'))->invoke($c); + return (new ReflectionProperty($request, 'options'))->getValue($request)['verify'] ?? null; +}; + +check('cert_path is used when verify_ssl is true', function () use ($verifyOption, $caPath) { + return $verifyOption($caPath, true) === $caPath ? 'verifies against the bundle' : false; +}); + +check('cert_path is STILL used when verify_ssl is false (no silent downgrade)', function () use ($verifyOption, $caPath) { + // 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. + return $verifyOption($caPath, false) === $caPath ? 'verifies against the bundle' : false; +}); + +check('verification stays on by default', function () use ($verifyOption) { + return $verifyOption(null, true) === true ? 'verify => true' : false; +}); + +check('verify_ssl false with no bundle still disables verification', function () use ($verifyOption) { + return $verifyOption(null, false) === false ? 'verify => false' : false; +}); + +check('no configuration verifies less than it did before this change', function () use ($verifyOption, $caPath): bool { + foreach ([[true, null], [true, $caPath], [false, null], [false, $caPath]] as [$verifySsl, $certPath]) { + // The behaviour of the original expression, before RFC 8705 support was added. + $before = ($verifySsl ?: $certPath) ?? false; + $now = $verifyOption($certPath, $verifySsl); + // Verification may be strengthened (false -> bundle/true) but never weakened. + if ($before !== false && $now === false) { + return false; + } + if (is_string($before) && $now !== $before) { + return false; + } + } + return true; +}); + echo "\n-- Regressions: secret-based clients still work --\n"; check('client_secret_basic client is unaffected by the RFC 8705 changes', function () use ($providerUrl, $caPath) { diff --git a/src/Client.php b/src/Client.php index d22d088d..4e973cea 100644 --- a/src/Client.php +++ b/src/Client.php @@ -77,8 +77,8 @@ class Client * @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 @@ -337,7 +337,11 @@ 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() ?? [] From f7f40fd3974c342f426f38741439339e22835a73 Mon Sep 17 00:00:00 2001 From: Corey Koval Date: Sat, 1 Aug 2026 21:58:29 -0400 Subject: [PATCH 3/8] =?UTF-8?q?chore:=20=F0=9F=A7=B9=20Untrack=20the=20loc?= =?UTF-8?q?al=20mutual-TLS=20Docker=20test=20harness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docker/ stack is local development tooling rather than part of the distributed package, so remove it from version control and ignore it. The files stay on disk for local RFC 8705 testing. Drop the README section that pointed at ./docker/run-tests.sh, since that script is no longer present in a fresh clone. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 4 + README.md | 20 -- docker/Dockerfile | 18 -- docker/certs/.gitignore | 3 - docker/docker-compose.yml | 53 ----- docker/realm-export.json | 78 ------- docker/run-tests.sh | 41 ---- docker/scripts/generate-certs.sh | 94 --------- docker/tests/rfc8705_test.php | 345 ------------------------------- 9 files changed, 4 insertions(+), 652 deletions(-) delete mode 100644 docker/Dockerfile delete mode 100644 docker/certs/.gitignore delete mode 100644 docker/docker-compose.yml delete mode 100644 docker/realm-export.json delete mode 100755 docker/run-tests.sh delete mode 100755 docker/scripts/generate-certs.sh delete mode 100644 docker/tests/rfc8705_test.php diff --git a/.gitignore b/.gitignore index 17b8d1a4..e0757da3 100644 --- a/.gitignore +++ b/.gitignore @@ -246,3 +246,7 @@ $RECYCLE.BIN/ *.lnk # End of https://www.toptal.com/developers/gitignore/api/git,linux,macos,windows,composer,phpstorm,visualstudiocode,phpunit + +### Local test harness ### +# RFC 8705 mutual-TLS Docker test stack — kept locally, not part of the package. +docker/ diff --git a/README.md b/README.md index 2f765a4c..ff430be5 100644 --- a/README.md +++ b/README.md @@ -299,26 +299,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. -### Mutual TLS (RFC 8705) -The mutual-TLS support has a self-contained test stack that needs nothing but Docker. It generates a -CA and client certificates, starts a Keycloak configured for mutual TLS and certificate-bound access -tokens, and runs the tests against it: - -```bash -./docker/run-tests.sh # run the tests and tear the stack down -./docker/run-tests.sh --keep # leave Keycloak running on https://localhost:8443 (admin/admin) -``` - -Besides the happy paths, the suite covers the cases that make the spec worth implementing: a token -request without a certificate is rejected, a certificate with the wrong subject DN is rejected, and a -certificate-bound access token is refused by the userinfo endpoint when the certificate is not -presented. Generated certificates stay out of git. - -Two notes on Keycloak as a test provider: it implements the PKI variant (`tls_client_auth`) and does -not advertise `self_signed_tls_client_auth`, and its trust store must be a Java trust store built -with `keytool` — a PKCS#12 file created by `openssl pkcs12 -export -nokeys` makes it abort the TLS -handshake. - ### Todo - Dynamic registration does not support registration auth tokens and endpoints diff --git a/docker/Dockerfile b/docker/Dockerfile deleted file mode 100644 index 62ee1e64..00000000 --- a/docker/Dockerfile +++ /dev/null @@ -1,18 +0,0 @@ -# PHP 8.4 — composer.lock pins Symfony 8 / Illuminate 13, which require PHP >= 8.4. -FROM php:8.4-cli - -RUN apt-get update && apt-get install -y --no-install-recommends \ - git unzip libzip-dev libicu-dev libxml2-dev openssl ca-certificates \ - && docker-php-ext-install -j"$(nproc)" zip intl dom xml \ - && rm -rf /var/lib/apt/lists/* - -COPY --from=composer:2 /usr/bin/composer /usr/bin/composer - -WORKDIR /app - -# Install dependencies first so they are cached independently of the source. -COPY composer.json composer.lock ./ -RUN composer install --no-interaction --no-progress --no-scripts --no-autoloader - -COPY src ./src -RUN composer dump-autoload --optimize diff --git a/docker/certs/.gitignore b/docker/certs/.gitignore deleted file mode 100644 index e0199e95..00000000 --- a/docker/certs/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Test certificates are generated by docker/scripts/generate-certs.sh — never commit keys. -* -!.gitignore diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml deleted file mode 100644 index a1f1eb1d..00000000 --- a/docker/docker-compose.yml +++ /dev/null @@ -1,53 +0,0 @@ -# Test stack for RFC 8705 (Mutual-TLS Client Authentication and Certificate-Bound Access Tokens). -# -# docker compose -f docker/docker-compose.yml run --rm tests -# -# Certificates are generated by docker/scripts/generate-certs.sh before starting the stack. -services: - keycloak: - image: quay.io/keycloak/keycloak:26.0 - command: - - start-dev - - --import-realm - - --https-certificate-file=/opt/keycloak/certs/keycloak.crt - - --https-certificate-key-file=/opt/keycloak/certs/keycloak.key - # Request — not require — a client certificate, so the discovery document and the - # regular endpoints stay reachable without one. - - --https-client-auth=request - - --https-trust-store-file=/opt/keycloak/certs/truststore.jks - - --https-trust-store-password=password - - --features=token-exchange - environment: - KC_BOOTSTRAP_ADMIN_USERNAME: admin - KC_BOOTSTRAP_ADMIN_PASSWORD: admin - KC_HTTP_ENABLED: "true" - KC_HOSTNAME_STRICT: "false" - volumes: - - ./certs:/opt/keycloak/certs:ro - - ./realm-export.json:/opt/keycloak/data/import/realm-export.json:ro - ports: - - "8080:8080" - - "8443:8443" - healthcheck: - # Keycloak 26 has no curl/wget in the image; use the JVM-less bash /dev/tcp probe. - test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/8443 || exit 1"] - interval: 5s - timeout: 5s - retries: 40 - start_period: 20s - - tests: - build: - context: .. - dockerfile: docker/Dockerfile - depends_on: - keycloak: - condition: service_healthy - environment: - KEYCLOAK_URL: https://keycloak:8443 - REALM: mtls - CERT_DIR: /app/docker/certs - volumes: - - ../src:/app/src:ro - - ../docker:/app/docker:ro - command: ["php", "/app/docker/tests/rfc8705_test.php"] diff --git a/docker/realm-export.json b/docker/realm-export.json deleted file mode 100644 index b221ca40..00000000 --- a/docker/realm-export.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "realm": "mtls", - "enabled": true, - "sslRequired": "none", - "registrationAllowed": false, - "users": [ - { - "username": "testuser", - "enabled": true, - "emailVerified": true, - "email": "testuser@example.com", - "firstName": "Test", - "lastName": "User", - "credentials": [ - { - "type": "password", - "value": "password", - "temporary": false - } - ], - "realmRoles": ["default-roles-mtls"] - } - ], - "clients": [ - { - "clientId": "mtls-client", - "name": "RFC 8705 PKI mutual-TLS client", - "enabled": true, - "protocol": "openid-connect", - "publicClient": false, - "bearerOnly": false, - "serviceAccountsEnabled": true, - "standardFlowEnabled": true, - "directAccessGrantsEnabled": true, - "redirectUris": ["*"], - "webOrigins": ["*"], - "clientAuthenticatorType": "client-x509", - "attributes": { - "x509.subjectdn": "CN=oidc-client-php", - "x509.allow.regex.pattern.comparison": "false", - "tls.client.certificate.bound.access.tokens": "true" - } - }, - { - "clientId": "mtls-client-self-signed", - "name": "RFC 8705 self-signed mutual-TLS client", - "enabled": true, - "protocol": "openid-connect", - "publicClient": false, - "bearerOnly": false, - "serviceAccountsEnabled": true, - "standardFlowEnabled": true, - "directAccessGrantsEnabled": true, - "redirectUris": ["*"], - "webOrigins": ["*"], - "clientAuthenticatorType": "client-x509", - "attributes": { - "x509.subjectdn": "CN=oidc-client-php-self-signed", - "x509.allow.regex.pattern.comparison": "false", - "tls.client.certificate.bound.access.tokens": "true" - } - }, - { - "clientId": "secret-client", - "name": "Client secret baseline (no mutual TLS)", - "enabled": true, - "protocol": "openid-connect", - "publicClient": false, - "bearerOnly": false, - "secret": "secret-client-password", - "serviceAccountsEnabled": true, - "standardFlowEnabled": true, - "directAccessGrantsEnabled": true, - "redirectUris": ["*"], - "webOrigins": ["*"] - } - ] -} diff --git a/docker/run-tests.sh b/docker/run-tests.sh deleted file mode 100755 index dd5cf780..00000000 --- a/docker/run-tests.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env bash -# -# Runs the RFC 8705 test stack end to end: generates certificates, starts Keycloak, -# runs the tests against it and tears the stack down again. -# -# ./docker/run-tests.sh run the tests -# ./docker/run-tests.sh --keep leave Keycloak running afterwards -# -set -euo pipefail - -cd "$(dirname "$0")/.." -COMPOSE="docker compose -f docker/docker-compose.yml" -KEEP=0 -[ "${1:-}" = "--keep" ] && KEEP=1 - -cleanup() { - if [ "$KEEP" -eq 0 ]; then - echo "==> Tearing down" - $COMPOSE down -v >/dev/null 2>&1 || true - else - echo "==> Keeping Keycloak running (https://localhost:8443, admin/admin)" - fi -} -trap cleanup EXIT - -echo "==> Generating certificates" -./docker/scripts/generate-certs.sh - -echo "==> Starting Keycloak" -$COMPOSE up -d keycloak - -echo "==> Waiting for Keycloak to become healthy" -for _ in $(seq 1 60); do - status="$(docker inspect -f '{{.State.Health.Status}}' docker-keycloak-1 2>/dev/null || echo starting)" - [ "$status" = "healthy" ] && break - sleep 5 -done -[ "${status:-}" = "healthy" ] || { echo "Keycloak did not become healthy"; $COMPOSE logs keycloak | tail -40; exit 1; } - -echo "==> Running RFC 8705 tests" -$COMPOSE run --rm tests diff --git a/docker/scripts/generate-certs.sh b/docker/scripts/generate-certs.sh deleted file mode 100755 index e4e04e98..00000000 --- a/docker/scripts/generate-certs.sh +++ /dev/null @@ -1,94 +0,0 @@ -#!/usr/bin/env sh -# -# Generates the PKI used by the RFC 8705 test stack: -# -# ca.crt / ca.key Certificate authority, trusted by Keycloak for client certs -# keycloak.crt/.key Keycloak server certificate (CN=keycloak) -# client.crt/.key Client certificate signed by the CA, for tls_client_auth -# client-self-signed.crt Self-signed client certificate, for self_signed_tls_client_auth -# other-client.crt/.key A second CA-signed certificate, to prove binding is enforced -# -set -eu - -CERT_DIR="$(CDPATH='' cd -- "$(dirname -- "$0")/../certs" && pwd)" -cd "$CERT_DIR" - -# The subject DN below must match the client's "Subject DN" in Keycloak (see realm-export.json). -CLIENT_SUBJECT="/CN=oidc-client-php" - -if [ -f client.crt ] && [ "${FORCE:-0}" != "1" ]; then - echo "Certificates already present in $CERT_DIR (set FORCE=1 to regenerate)" - exit 0 -fi - -echo "Generating certificates in $CERT_DIR" - -# --- Certificate authority ------------------------------------------------- -openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \ - -keyout ca.key -out ca.crt \ - -subj "/CN=OIDC Test CA" 2>/dev/null - -# --- Keycloak server certificate ------------------------------------------- -openssl req -newkey rsa:2048 -nodes \ - -keyout keycloak.key -out keycloak.csr \ - -subj "/CN=keycloak" 2>/dev/null - -# SANs so the cert is valid both inside the compose network and from the host -cat > keycloak.ext <<'EOF' -subjectAltName = DNS:keycloak, DNS:localhost, IP:127.0.0.1 -extendedKeyUsage = serverAuth -EOF - -openssl x509 -req -in keycloak.csr -CA ca.crt -CAkey ca.key -CAcreateserial \ - -out keycloak.crt -days 3650 -extfile keycloak.ext 2>/dev/null - -# --- CA-signed client certificate (tls_client_auth) ------------------------ -openssl req -newkey rsa:2048 -nodes \ - -keyout client.key -out client.csr \ - -subj "$CLIENT_SUBJECT" 2>/dev/null - -cat > client.ext <<'EOF' -extendedKeyUsage = clientAuth -EOF - -openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial \ - -out client.crt -days 3650 -extfile client.ext 2>/dev/null - -# --- Self-signed client certificate (self_signed_tls_client_auth) ---------- -openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \ - -keyout client-self-signed.key -out client-self-signed.crt \ - -subj "/CN=oidc-client-php-self-signed" 2>/dev/null - -# --- A different CA-signed client certificate ------------------------------ -# Used to prove that a token bound to `client.crt` is rejected for this one. -openssl req -newkey rsa:2048 -nodes \ - -keyout other-client.key -out other-client.csr \ - -subj "/CN=other-client" 2>/dev/null - -openssl x509 -req -in other-client.csr -CA ca.crt -CAkey ca.key -CAcreateserial \ - -out other-client.crt -days 3650 -extfile client.ext 2>/dev/null - -# Keycloak needs a truststore holding the CA to validate presented client certificates. -# It must be a Java trust store with a "trustedCertEntry" — a PKCS#12 file built by -# `openssl pkcs12 -export -nokeys` is a key store and makes Keycloak abort the handshake. -# -# The self-signed client certificate is added as well: with self_signed_tls_client_auth the -# certificate has no issuing CA, so the TLS layer can only accept it if it is trusted directly. -rm -f truststore.jks -docker run --rm -v "$CERT_DIR:/c" -w /c eclipse-temurin:21-jdk sh -c ' - keytool -importcert -noprompt -trustcacerts -alias ca \ - -file ca.crt -keystore truststore.jks -storepass password && - keytool -importcert -noprompt -trustcacerts -alias self-signed-client \ - -file client-self-signed.crt -keystore truststore.jks -storepass password -' >/dev/null 2>&1 - -rm -f ./*.csr ./*.ext ./*.srl -chmod 644 ./*.key ./*.crt -chmod 644 truststore.jks 2>/dev/null || true - -echo "Client certificate SHA-256 thumbprint (base64url), as it appears in cnf/x5t#S256:" -openssl x509 -in client.crt -outform DER 2>/dev/null \ - | openssl dgst -sha256 -binary \ - | openssl base64 \ - | tr '+/' '-_' | tr -d '=\n' -echo "" diff --git a/docker/tests/rfc8705_test.php b/docker/tests/rfc8705_test.php deleted file mode 100644 index 47bc28c0..00000000 --- a/docker/tests/rfc8705_test.php +++ /dev/null @@ -1,345 +0,0 @@ -getMessage() . "\n"; - $failed++; - } -} - -/** Decodes a JWT payload without verifying it (the provider is trusted in this test). */ -function jwtClaims(string $jwt): array -{ - $parts = explode('.', $jwt); - return json_decode(base64_decode(strtr($parts[1], '-_', '+/')), true, 512, JSON_THROW_ON_ERROR); -} - -$clientCert = new MutualTlsCertificate("$certDir/client.crt", "$certDir/client.key"); -$selfSignedCert = new MutualTlsCertificate("$certDir/client-self-signed.crt", "$certDir/client-self-signed.key"); -$otherCert = new MutualTlsCertificate("$certDir/other-client.crt", "$certDir/other-client.key"); - -// The stack uses a private CA, so trust it rather than disabling verification. -$caPath = "$certDir/ca.crt"; - -echo "\n=== RFC 8705 against $providerUrl ===\n\n"; - -echo "-- Discovery (RFC 8705 section 5) --\n"; - -$client = new Client( - client_id: 'mtls-client', - provider_url: $providerUrl, - redirect_uri: 'https://example.com/callback', - token_endpoint_auth_method: ClientAuthMethod::TLS_CLIENT_AUTH, - mtls_certificate: $clientCert, - cert_path: $caPath, -); - -check('discovery advertises tls_client_auth', function () use ($client): bool { - return in_array(ClientAuthMethod::TLS_CLIENT_AUTH, $client->token_endpoint_auth_methods_supported, true); -}); - -check('self_signed_tls_client_auth is parsed from discovery metadata', function (): bool { - // Keycloak's client-x509 authenticator only implements the PKI variant, so it does not - // advertise this method. Assert the enum parses the metadata value instead of asserting - // a provider capability that this particular provider does not have. - return ClientAuthMethod::tryFrom('self_signed_tls_client_auth') === ClientAuthMethod::SELF_SIGNED_TLS_CLIENT_AUTH - && ClientAuthMethod::SELF_SIGNED_TLS_CLIENT_AUTH->isMutualTls(); -}); - -check('tls_client_certificate_bound_access_tokens parsed', function () use ($client): bool { - return $client->tls_client_certificate_bound_access_tokens === true; -}); - -check('mtls_endpoint_aliases parsed and applied to the token endpoint', function () use ($client) { - $aliases = (new ReflectionProperty($client, 'mtls_endpoint_aliases'))->getValue($client); - if (empty($aliases)) { - // Keycloak only emits aliases when configured to; not fatal, the regular endpoint is used. - return 'no aliases advertised, falling back to the regular endpoint'; - } - $resolved = (new ReflectionMethod($client, 'mtlsEndpoint')) - ->invoke($client, 'token', $client->token_endpoint); - return $resolved === ($aliases['token_endpoint'] ?? $client->token_endpoint) - ? 'alias applied' - : false; -}); - -check('usesMutualTlsClientAuth() is true', fn (): bool => $client->usesMutualTlsClientAuth() === true); - -echo "\n-- tls_client_auth: PKI mutual-TLS client authentication (section 2.1) --\n"; - -/** - * Requests a token with the client_credentials grant, which exercises exactly the same - * client-authentication path as the authorization_code grant without needing a browser. - */ -$requestToken = static function (Client $c, MutualTlsCertificate $cert, ?string $ca) use ($providerUrl): array { - $data = ['grant_type' => 'client_credentials', 'client_id' => $c->client_id, 'scope' => 'openid']; - // Route through the client's own applyMutualTlsClientAuth so the code under test decides - // what is sent, then post with the same options the library would use. - (new ReflectionMethod($c, 'applyMutualTlsClientAuth'))->invokeArgs($c, [&$data]); - - $response = (new Factory())->withOptions([ - 'verify' => $ca ?? false, - ...$cert->getRequestOptions(), - ])->asForm()->post("$providerUrl/protocol/openid-connect/token", $data); - - return [$response->status(), $response->json() ?? []]; -}; - -$boundAccessToken = null; - -check('token request succeeds with the client certificate and no client_secret', function () use ($requestToken, $client, $clientCert, $caPath, &$boundAccessToken) { - [$status, $body] = $requestToken($client, $clientCert, $caPath); - if ($status !== 200) { - echo ' response: ' . json_encode($body) . "\n"; - return false; - } - $boundAccessToken = $body['access_token'] ?? null; - return is_string($boundAccessToken) ? 'got an access token' : false; -}); - -check('no client_secret is sent under mutual TLS', function () use ($client): bool { - $data = ['grant_type' => 'client_credentials', 'client_id' => 'mtls-client', 'client_secret' => 'should-be-removed']; - (new ReflectionMethod($client, 'applyMutualTlsClientAuth'))->invokeArgs($client, [&$data]); - return !array_key_exists('client_secret', $data) && $data['client_id'] === 'mtls-client'; -}); - -check('token request FAILS without the client certificate', function () use ($providerUrl, $caPath) { - $response = (new Factory())->withOptions(['verify' => $caPath])->asForm()->post( - "$providerUrl/protocol/openid-connect/token", - ['grant_type' => 'client_credentials', 'client_id' => 'mtls-client', 'scope' => 'openid'] - ); - // The provider must reject a client that presents no certificate. - return $response->status() !== 200 ? 'rejected with HTTP ' . $response->status() : false; -}); - -check('token request FAILS with a certificate whose subject DN does not match', function () use ($requestToken, $client, $otherCert, $caPath) { - [$status, $body] = $requestToken($client, $otherCert, $caPath); - return $status !== 200 ? 'rejected with HTTP ' . $status : false; -}); - -echo "\n-- Certificate-bound access tokens (section 3) --\n"; - -check('access token carries the cnf/x5t#S256 confirmation claim', function () use (&$boundAccessToken) { - if (!is_string($boundAccessToken)) { - return false; - } - $claims = jwtClaims($boundAccessToken); - return isset($claims['cnf']['x5t#S256']) - ? 'x5t#S256=' . $claims['cnf']['x5t#S256'] - : false; -}); - -check('cnf/x5t#S256 equals the thumbprint computed by MutualTlsCertificate', function () use (&$boundAccessToken, $clientCert) { - $claims = jwtClaims($boundAccessToken); - return hash_equals($clientCert->getThumbprint(), $claims['cnf']['x5t#S256']) - ? $clientCert->getThumbprint() - : false; -}); - -check('verifyCertificateBinding() accepts a token bound to our certificate', function () use ($client, &$boundAccessToken): bool { - return $client->verifyCertificateBinding(collect(jwtClaims($boundAccessToken))) === true; -}); - -check('verifyCertificateBinding() REJECTS a token bound to another certificate', function () use ($providerUrl, $otherCert, $caPath, &$boundAccessToken) { - $wrongClient = new Client( - client_id: 'mtls-client', - provider_url: $providerUrl, - redirect_uri: 'https://example.com/callback', - token_endpoint_auth_method: ClientAuthMethod::TLS_CLIENT_AUTH, - mtls_certificate: $otherCert, - cert_path: $caPath, - ); - try { - $wrongClient->verifyCertificateBinding(collect(jwtClaims($boundAccessToken))); - return false; // should have thrown - } catch (OIDCClientException) { - return 'threw OIDCClientException as expected'; - } -}); - -check('verifyCertificateBinding() returns false for a token with no cnf claim', function () use ($client): bool { - return $client->verifyCertificateBinding(collect(['sub' => 'nobody'])) === false; -}); - -check('userinfo accepts the certificate-bound token when the certificate is presented', function () use ($providerUrl, $clientCert, $caPath, &$boundAccessToken) { - $response = (new Factory())->withOptions([ - 'verify' => $caPath, - ...$clientCert->getRequestOptions(), - ])->withToken($boundAccessToken)->acceptJson()->get("$providerUrl/protocol/openid-connect/userinfo"); - return $response->ok() ? 'HTTP 200' : 'unexpected HTTP ' . $response->status(); -}); - -check('userinfo REJECTS the certificate-bound token when NO certificate is presented', function () use ($providerUrl, $caPath, &$boundAccessToken) { - $response = (new Factory())->withOptions(['verify' => $caPath]) - ->withToken($boundAccessToken)->acceptJson() - ->get("$providerUrl/protocol/openid-connect/userinfo"); - // This is the whole point of certificate binding: a stolen token is useless without the key. - return !$response->ok() ? 'rejected with HTTP ' . $response->status() : false; -}); - -echo "\n-- self_signed_tls_client_auth (section 2.2) --\n"; - -$selfSignedClient = new Client( - client_id: 'mtls-client-self-signed', - provider_url: $providerUrl, - redirect_uri: 'https://example.com/callback', - token_endpoint_auth_method: ClientAuthMethod::SELF_SIGNED_TLS_CLIENT_AUTH, - mtls_certificate: $selfSignedCert, - cert_path: $caPath, -); - -check('self-signed certificate is presented, authenticates and yields a bound token', function () use ($requestToken, $selfSignedClient, $selfSignedCert, $caPath) { - // The certificate is in Keycloak's trust store, so the handshake succeeds; Keycloak then - // authenticates the client by matching the certificate subject DN. - [$status, $body] = $requestToken($selfSignedClient, $selfSignedCert, $caPath); - if ($status !== 200) { - echo ' response: ' . json_encode($body) . "\n"; - return false; - } - $claims = jwtClaims($body['access_token']); - $thumb = $claims['cnf']['x5t#S256'] ?? null; - return $thumb !== null && hash_equals($selfSignedCert->getThumbprint(), $thumb) - ? 'bound to the self-signed certificate' - : false; -}); - -echo "\n-- TLS verification: cert_path must never silently stop verifying --\n"; - -/** The Guzzle `verify` option the client would use for the given configuration. */ -$verifyOption = static function (?string $certPath, bool $verifySsl) use ($providerUrl): mixed { - $c = new Client( - client_id: 'secret-client', - client_secret: 'secret-client-password', - // No provider_url: auto discovery would need a working connection, and only the - // resulting request options matter here. - redirect_uri: 'https://example.com/callback', - cert_path: $certPath, - verify_ssl: $verifySsl, - ); - $request = (new ReflectionMethod($c, 'client'))->invoke($c); - return (new ReflectionProperty($request, 'options'))->getValue($request)['verify'] ?? null; -}; - -check('cert_path is used when verify_ssl is true', function () use ($verifyOption, $caPath) { - return $verifyOption($caPath, true) === $caPath ? 'verifies against the bundle' : false; -}); - -check('cert_path is STILL used when verify_ssl is false (no silent downgrade)', function () use ($verifyOption, $caPath) { - // 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. - return $verifyOption($caPath, false) === $caPath ? 'verifies against the bundle' : false; -}); - -check('verification stays on by default', function () use ($verifyOption) { - return $verifyOption(null, true) === true ? 'verify => true' : false; -}); - -check('verify_ssl false with no bundle still disables verification', function () use ($verifyOption) { - return $verifyOption(null, false) === false ? 'verify => false' : false; -}); - -check('no configuration verifies less than it did before this change', function () use ($verifyOption, $caPath): bool { - foreach ([[true, null], [true, $caPath], [false, null], [false, $caPath]] as [$verifySsl, $certPath]) { - // The behaviour of the original expression, before RFC 8705 support was added. - $before = ($verifySsl ?: $certPath) ?? false; - $now = $verifyOption($certPath, $verifySsl); - // Verification may be strengthened (false -> bundle/true) but never weakened. - if ($before !== false && $now === false) { - return false; - } - if (is_string($before) && $now !== $before) { - return false; - } - } - return true; -}); - -echo "\n-- Regressions: secret-based clients still work --\n"; - -check('client_secret_basic client is unaffected by the RFC 8705 changes', function () use ($providerUrl, $caPath) { - $secretClient = new Client( - client_id: 'secret-client', - client_secret: 'secret-client-password', - provider_url: $providerUrl, - redirect_uri: 'https://example.com/callback', - cert_path: $caPath, - ); - // No certificate configured, so mutual TLS must stay entirely out of the way. - if ($secretClient->usesMutualTlsClientAuth()) { - return false; - } - $data = ['grant_type' => 'client_credentials', 'client_id' => 'secret-client', 'client_secret' => 'secret-client-password']; - $applied = (new ReflectionMethod($secretClient, 'applyMutualTlsClientAuth'))->invokeArgs($secretClient, [&$data]); - if ($applied || !isset($data['client_secret'])) { - return false; // the secret must be left alone - } - $response = (new Factory())->withOptions(['verify' => $caPath])->asForm() - ->post("$providerUrl/protocol/openid-connect/token", $data); - return $response->ok() ? 'still authenticates with its secret' : false; -}); - -check('endpoints are unchanged when no certificate is configured', function () use ($providerUrl, $caPath): bool { - $plain = new Client( - client_id: 'secret-client', - client_secret: 'secret-client-password', - provider_url: $providerUrl, - redirect_uri: 'https://example.com/callback', - cert_path: $caPath, - ); - $resolved = (new ReflectionMethod($plain, 'mtlsEndpoint'))->invoke($plain, 'token', $plain->token_endpoint); - return $resolved === $plain->token_endpoint; -}); - -check('mutual-TLS auth method without a certificate throws a clear error', function () use ($providerUrl, $caPath) { - $noCert = new Client( - client_id: 'mtls-client', - provider_url: $providerUrl, - redirect_uri: 'https://example.com/callback', - token_endpoint_auth_method: ClientAuthMethod::TLS_CLIENT_AUTH, - cert_path: $caPath, - ); - $data = ['grant_type' => 'client_credentials']; - try { - (new ReflectionMethod($noCert, 'applyMutualTlsClientAuth'))->invokeArgs($noCert, [&$data]); - return false; - } catch (OIDCClientException $e) { - return str_contains($e->getMessage(), 'requires a client certificate') ? 'clear error' : false; - } -}); - -echo "\n=== $passed passed, $failed failed ===\n\n"; -exit($failed === 0 ? 0 : 1); From 05665dff90de362908a7df88ddc8661bdf28f4bb Mon Sep 17 00:00:00 2001 From: Corey Koval Date: Sun, 2 Aug 2026 08:52:49 -0400 Subject: [PATCH 4/8] =?UTF-8?q?test:=20=F0=9F=A7=AA=20Cover=20RFC=208705?= =?UTF-8?q?=20mutual-TLS=20in=20the=20Pest=20suite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the mutual-TLS checks from the local Docker harness into tests/Feature so the RFC 8705 support is covered by the repo's own suite. The tests generate their own certificates and configure clients without a provider_url, so they need neither fixture files nor a running provider. Covered: certificate request options and thumbprints, auth method selection, dropping the client secret under mutual TLS, endpoint aliases, certificate bound token verification, and that a CA bundle keeps verifying the provider even alongside verify_ssl: false. The handshake behaviour of a real provider — a token request being rejected without a certificate, and a bound token being refused at userinfo — still needs a live mutual-TLS provider and is not covered here. Add a setProperty() reflection helper to TestCase alongside getProperty(). Co-Authored-By: Claude Opus 5 (1M context) --- tests/Feature/MutualTlsTest.php | 269 ++++++++++++++++++++++++++++++++ tests/Pest.php | 69 +++++++- tests/TestCase.php | 13 ++ 3 files changed, 349 insertions(+), 2 deletions(-) create mode 100644 tests/Feature/MutualTlsTest.php diff --git a/tests/Feature/MutualTlsTest.php b/tests/Feature/MutualTlsTest.php new file mode 100644 index 00000000..661366ea --- /dev/null +++ b/tests/Feature/MutualTlsTest.php @@ -0,0 +1,269 @@ +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_') . '.pem'; + file_put_contents($bundle, file_get_contents(certificate('client')->certificate_path) + . file_get_contents(certificate('client')->private_key_path)); + + expect((new MutualTlsCertificate($bundle))->getRequestOptions())->toBe(['cert' => $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_') . '.pem'; + file_put_contents($bundle, file_get_contents($certificate->certificate_path) + . file_get_contents($certificate->private_key_path)); + + expect((new MutualTlsCertificate($bundle))->getThumbprint())->toBe($certificate->getThumbprint()); +}); + +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_') . '.pem'; + file_put_contents($garbage, 'not a certificate'); + + expect(fn () => (new MutualTlsCertificate($garbage))->getThumbprint()) + ->toThrow(OIDCClientException::class, 'Unable to parse the client certificate'); +}); + +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'])->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('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..c2cc258e 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,69 @@ | */ -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]); + $csr = openssl_csr_new(['commonName' => $name], $key, ['digest_alg' => 'sha256']); + openssl_x509_export(openssl_csr_sign($csr, null, $key, 1, ['digest_alg' => 'sha256']), $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); + + $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); + } } From 71b0fa1bc6c17cb4f66051d1a27abd7c1a9b866f Mon Sep 17 00:00:00 2001 From: Corey Koval Date: Sun, 2 Aug 2026 09:28:56 -0400 Subject: [PATCH 5/8] =?UTF-8?q?chore:=20=F0=9F=A7=B9=20Ignore=20the=20loca?= =?UTF-8?q?l=20Docker=20test=20harness=20locally=20instead?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docker/ ignore rule only matters to this working copy, so keep it out of the committed .gitignore and move it to .git/info/exclude. The stack is still ignored locally; the shared ignore file returns to upstream defaults. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.gitignore b/.gitignore index e0757da3..17b8d1a4 100644 --- a/.gitignore +++ b/.gitignore @@ -246,7 +246,3 @@ $RECYCLE.BIN/ *.lnk # End of https://www.toptal.com/developers/gitignore/api/git,linux,macos,windows,composer,phpstorm,visualstudiocode,phpunit - -### Local test harness ### -# RFC 8705 mutual-TLS Docker test stack — kept locally, not part of the package. -docker/ From ac51d0e4a2e46ec338678b7817d2bf8882d363c6 Mon Sep 17 00:00:00 2001 From: Corey Koval Date: Mon, 3 Aug 2026 16:01:30 -0400 Subject: [PATCH 6/8] =?UTF-8?q?fix(auth):=20=F0=9F=94=92=20Use=20the=20mut?= =?UTF-8?q?ual-TLS=20endpoint=20aliases=20whenever=20mutual=20TLS=20applie?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two RFC 8705 endpoint-alias bugs. `tls_client_certificate_bound_access_tokens` was parsed from discovery and sent during registration, but never consulted when resolving endpoints: `mtlsEndpoint()` keyed solely off the presence of a certificate. A client authenticating with a secret while asking for certificate-bound tokens therefore kept using the regular token endpoint, where no certificate is requested during the handshake, so the token could not be bound. Resolution now goes through `usesMutualTls()`, covering both reasons the aliases exist: mutual-TLS client authentication, and certificate-bound access tokens. This also stops a certificate that is used for neither purpose from diverting endpoints, which the old check did. `register()` posted to `registration_endpoint` directly while the other four endpoints were resolved through the aliases. It is where a mutual-TLS client announces its `token_endpoint_auth_method`, so it is the call that most needs the certificate presented. Verified against the local Keycloak stack (29/29) and the Pest suite, including an end-to-end check that `register()` posts to the aliased URL. Co-Authored-By: Claude Opus 5 (1M context) --- src/Traits/DynamicRegistration.php | 4 ++- src/Traits/MutualTls.php | 17 ++++++++++++- tests/Feature/MutualTlsTest.php | 41 ++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/Traits/DynamicRegistration.php b/src/Traits/DynamicRegistration.php index 4dc922de..3d452407 100644 --- a/src/Traits/DynamicRegistration.php +++ b/src/Traits/DynamicRegistration.php @@ -41,7 +41,9 @@ public function register(?array $params = null): void $data->put('tls_client_certificate_bound_access_tokens', true); } - $response = $this->client()->post($this->registration_endpoint, $data->all())->collect(); + $response = $this->client() + ->post($this->mtlsEndpoint('registration', $this->registration_endpoint), $data->all()) + ->collect(); $error = $response->get('error_description'); if ($error) { diff --git a/src/Traits/MutualTls.php b/src/Traits/MutualTls.php index 7c7e5b93..4be50841 100644 --- a/src/Traits/MutualTls.php +++ b/src/Traits/MutualTls.php @@ -82,13 +82,28 @@ private function getClientAuthMethod(): ?ClientAuthMethod */ private function mtlsEndpoint(string $name, ?string $endpoint): ?string { - if ($this->mtls_certificate === null) { + 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. * diff --git a/tests/Feature/MutualTlsTest.php b/tests/Feature/MutualTlsTest.php index 661366ea..f0befa37 100644 --- a/tests/Feature/MutualTlsTest.php +++ b/tests/Feature/MutualTlsTest.php @@ -196,6 +196,47 @@ ->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(); From 6c6f3737f2ebd3e5e799fe42f3bf4b512a27f038 Mon Sep 17 00:00:00 2001 From: Corey Koval Date: Tue, 4 Aug 2026 16:11:44 -0400 Subject: [PATCH 7/8] =?UTF-8?q?fix(auth):=20=F0=9F=94=92=20Address=20the?= =?UTF-8?q?=20RFC=208705=20review=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introspection and revocation used usesMutualTlsClientAuth(), which is false when a mutual-TLS method is configured without a certificate. Both then fell back to Basic authentication with a secret a mutual-TLS client does not have, and the provider answered with an opaque 401. They now go through applyMutualTlsClientAuth() like refreshToken() and requestTokens() already did, so the misconfiguration raises a clear OIDCClientException instead. The client_id override both methods accept is preserved. getClientAuthMethod() typed its closure parameter as ClientAuthMethod, but token_endpoint_auth_methods_supported is a public array typed only by docblock and constructor input is never normalised — only discovered values are. Method names passed as strings raised a TypeError, so entries are now normalised through tryFrom(). preg_replace() returns null when the PCRE engine fails, for example on a backtrack limit with a large bundle. base64_decode(null) is deprecated on the PHP versions this package supports and yields an empty string, so the result is cast to keep the clean OIDCClientException path. Tests: tempnam() creates a file, so appending '.pem' leaked an empty temp file on every run; the returned path is reused and cleaned up instead. The openssl calls behind the test certificates are checked for failure, the generated key is restricted to 0600 to match the 0700 directory, and one assertion uses a coalescing access that PHPStan can follow through invokeArgs(). README: document the RFC 8705 metadata callers must pass to register(), a subject identifier for tls_client_auth (section 2.1.2) and jwks/jwks_uri for self_signed_tls_client_auth (section 2.2.2). Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 16 ++++++++++++++++ src/MutualTlsCertificate.php | 2 +- src/Traits/MutualTls.php | 7 ++++++- src/Traits/Token.php | 4 ++-- tests/Feature/MutualTlsTest.php | 28 ++++++++++++++++++++-------- tests/Pest.php | 16 +++++++++++++++- 6 files changed, 60 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index ff430be5..865b6e11 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,22 @@ $oidc = new Client( ); ``` +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 diff --git a/src/MutualTlsCertificate.php b/src/MutualTlsCertificate.php index 4366ef9b..48b32230 100644 --- a/src/MutualTlsCertificate.php +++ b/src/MutualTlsCertificate.php @@ -95,7 +95,7 @@ public function getThumbprint(): string // so hash the first certificate block only. $der = null; if (preg_match('/-----BEGIN CERTIFICATE-----(.+?)-----END CERTIFICATE-----/s', $contents, $matches)) { - $der = base64_decode(preg_replace('/\s+/', '', $matches[1]), true); + $der = base64_decode((string) preg_replace('/\s+/', '', $matches[1]), true); } if (empty($der)) { diff --git a/src/Traits/MutualTls.php b/src/Traits/MutualTls.php index 4be50841..0f6b4ded 100644 --- a/src/Traits/MutualTls.php +++ b/src/Traits/MutualTls.php @@ -65,8 +65,13 @@ private function getClientAuthMethod(): ?ClientAuthMethod 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) - ->first(static fn (ClientAuthMethod $method): bool => $method->isMutualTls()); + ->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); } /** diff --git a/src/Traits/Token.php b/src/Traits/Token.php index dad02558..e2ea14e1 100644 --- a/src/Traits/Token.php +++ b/src/Traits/Token.php @@ -92,7 +92,7 @@ public function introspectToken( $client = $this->client(); // With mutual TLS the certificate authenticates the client, so no secret is sent - if ($this->usesMutualTlsClientAuth()) { + if ($this->applyMutualTlsClientAuth($data)) { $data['client_id'] = $client_id; } else { $client = $client->withBasicAuth($client_id, $client_secret); @@ -127,7 +127,7 @@ public function revokeToken( $client = $this->client(); // With mutual TLS the certificate authenticates the client, so no secret is sent - if ($this->usesMutualTlsClientAuth()) { + if ($this->applyMutualTlsClientAuth($data)) { $data['client_id'] = $client_id; } else { $client = $client->withBasicAuth($client_id, $client_secret); diff --git a/tests/Feature/MutualTlsTest.php b/tests/Feature/MutualTlsTest.php index f0befa37..6d9162a9 100644 --- a/tests/Feature/MutualTlsTest.php +++ b/tests/Feature/MutualTlsTest.php @@ -51,11 +51,15 @@ }); test('certificate without a separate private key omits the ssl_key option', function () { - $bundle = tempnam(sys_get_temp_dir(), 'oidc_bundle_') . '.pem'; + $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)); - expect((new MutualTlsCertificate($bundle))->getRequestOptions())->toBe(['cert' => $bundle]); + 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 () { @@ -73,11 +77,15 @@ test('certificate thumbprint ignores a private key bundled with the certificate', function () { $certificate = certificate('client'); - $bundle = tempnam(sys_get_temp_dir(), 'oidc_bundle_') . '.pem'; + $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)); - expect((new MutualTlsCertificate($bundle))->getThumbprint())->toBe($certificate->getThumbprint()); + try { + expect((new MutualTlsCertificate($bundle))->getThumbprint())->toBe($certificate->getThumbprint()); + } finally { + unlink($bundle); + } }); test('certificate thumbprints differ between certificates', function () { @@ -93,11 +101,15 @@ }); test('unparseable certificate is rejected when computing the thumbprint', function () { - $garbage = tempnam(sys_get_temp_dir(), 'oidc_garbage_') . '.pem'; + $garbage = tempnam(sys_get_temp_dir(), 'oidc_garbage_'); file_put_contents($garbage, 'not a certificate'); - expect(fn () => (new MutualTlsCertificate($garbage))->getThumbprint()) - ->toThrow(OIDCClientException::class, 'Unable to parse the client 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 () { @@ -156,7 +168,7 @@ expect($this->invokeMethod(mtlsClient(), 'applyMutualTlsClientAuth', [&$data]))->toBeTrue() ->and($data)->not->toHaveKey('client_secret') - ->and($data['client_id'])->toBe('mtls-client'); + ->and($data['client_id'] ?? null)->toBe('mtls-client'); }); test('a secret based client keeps its client secret', function () { diff --git a/tests/Pest.php b/tests/Pest.php index c2cc258e..fe6caa22 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -70,8 +70,21 @@ function certificate(string $name = 'client'): MutualTlsCertificate 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']); - openssl_x509_export(openssl_csr_sign($csr, null, $key, 1, ['digest_alg' => 'sha256']), $certificate); + 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'; @@ -81,6 +94,7 @@ function certificate(string $name = 'client'): MutualTlsCertificate 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"); } From b5b73066e70c9c4a2962e33b3dab1a7d04e2ab88 Mon Sep 17 00:00:00 2001 From: Corey Koval Date: Tue, 4 Aug 2026 16:11:58 -0400 Subject: [PATCH 8/8] =?UTF-8?q?fix(token):=20=F0=9F=94=92=20Send=20the=20r?= =?UTF-8?q?evocation=20request=20form=20encoded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit revokeToken() called acceptJson() without asForm(), so the body went out as JSON. RFC 7009 section 2.1 requires application/x-www-form-urlencoded, and a provider that cannot read client_id from the body rejects the request with invalid_client — revocation never worked for any client. Verified against Keycloak: same endpoint and same credentials, only the encoding differs. acceptJson only: 401 {"error":"invalid_client", ...} asForm + acceptJson: 200 This predates the RFC 8705 work, but the mutual-TLS tests are what reached the revocation endpoint for the first time and surfaced it. Co-Authored-By: Claude Opus 5 (1M context) --- src/Traits/Token.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Traits/Token.php b/src/Traits/Token.php index e2ea14e1..f4a2f4c0 100644 --- a/src/Traits/Token.php +++ b/src/Traits/Token.php @@ -133,7 +133,10 @@ public function revokeToken( $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->mtlsEndpoint('revocation', $this->revocation_endpoint), $data) ->collect();