diff --git a/README.md b/README.md index 78a1aac..537d461 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,15 @@ token validator. Never expose a client secret or token in browser code. The administrative API is intentionally excluded from this package. +## Transport and correlation + +All discovery, token, and JWKS requests enforce certificate and hostname +verification, reject HTTP redirects, and use bounded connect/read timeouts. +Callers may pass a safe correlation identifier as the optional final argument +to `discover`, `exchange`, and ID-token validation methods; the SDK propagates +it as `X-Correlation-ID`. The SDK rejects malformed values and never logs +authorization codes, client secrets, or tokens. + ## Security Only explicit HTTPS endpoints are accepted. JWT signing is pinned to RS256, keys diff --git a/src/Oidc/AuthorizationCodeClient.php b/src/Oidc/AuthorizationCodeClient.php index 3d19386..1b56eb3 100644 --- a/src/Oidc/AuthorizationCodeClient.php +++ b/src/Oidc/AuthorizationCodeClient.php @@ -10,7 +10,7 @@ public function __construct(private readonly ClientInterface $http) { } - public function exchange(OidcClientConfiguration $configuration, string $code, string $codeVerifier): OidcTokenSet + public function exchange(OidcClientConfiguration $configuration, string $code, string $codeVerifier, ?string $correlationId = null): OidcTokenSet { if ($code === '' || $codeVerifier === '') { throw new OidcException('Authorization code and PKCE verifier are required.'); @@ -29,10 +29,11 @@ public function exchange(OidcClientConfiguration $configuration, string $code, s try { $response = $this->http->request('POST', $configuration->tokenEndpoint, [ - 'timeout' => $configuration->httpTimeoutSeconds, - 'connect_timeout' => $configuration->httpTimeoutSeconds, - 'http_errors' => false, - 'headers' => ['Accept' => 'application/json'], + ...OidcHttpRequestOptions::strict( + $configuration->httpTimeoutSeconds, + ['Accept' => 'application/json'], + $correlationId, + ), 'form_params' => $form, ]); } catch (\Throwable $exception) { diff --git a/src/Oidc/IdTokenValidator.php b/src/Oidc/IdTokenValidator.php index c414d76..2d2e5cc 100644 --- a/src/Oidc/IdTokenValidator.php +++ b/src/Oidc/IdTokenValidator.php @@ -18,9 +18,9 @@ public function __construct(private readonly ClientInterface $http) /** @return array */ /** @return array */ - public function validate(OidcClientConfiguration $configuration, string $idToken, ?string $expectedNonce = null): array + public function validate(OidcClientConfiguration $configuration, string $idToken, ?string $expectedNonce = null, ?string $correlationId = null): array { - $payload = $this->verifiedClaims($configuration, $idToken); + $payload = $this->verifiedClaims($configuration, $idToken, $correlationId); $this->assertIssuerAndAudience($configuration, $payload); if (trim((string) ($payload['sub'] ?? '')) === '') { throw new OidcException('ID token subject is missing.'); @@ -37,9 +37,9 @@ public function validate(OidcClientConfiguration $configuration, string $idToken } /** @return array */ - public function validateLogoutToken(OidcClientConfiguration $configuration, string $logoutToken): array + public function validateLogoutToken(OidcClientConfiguration $configuration, string $logoutToken, ?string $correlationId = null): array { - $payload = $this->verifiedClaims($configuration, $logoutToken); + $payload = $this->verifiedClaims($configuration, $logoutToken, $correlationId); $this->assertIssuerAndAudience($configuration, $payload); if (! is_numeric($payload['iat'] ?? null) || (int) $payload['iat'] > time() + 30 || trim((string) ($payload['jti'] ?? '')) === '') { throw new OidcException('Back-channel logout token time or identifier claims are invalid.'); @@ -49,18 +49,18 @@ public function validateLogoutToken(OidcClientConfiguration $configuration, stri } /** @return array */ - private function verifiedClaims(OidcClientConfiguration $configuration, string $token): array + private function verifiedClaims(OidcClientConfiguration $configuration, string $token, ?string $correlationId): array { $header = $this->decodeHeader($token); if (($header['alg'] ?? null) !== 'RS256' || ! is_string($header['kid'] ?? null) || $header['kid'] === '') { throw new OidcException('JWT header does not use an allowed signing algorithm.'); } - $keys = $this->jwks($configuration->jwksUri, $configuration->httpTimeoutSeconds, false); + $keys = $this->jwks($configuration->jwksUri, $configuration->httpTimeoutSeconds, false, $correlationId); $keySet = JWK::parseKeySet($keys); $kid = $header['kid']; if (! isset($keySet[$kid])) { - $keySet = JWK::parseKeySet($this->jwks($configuration->jwksUri, $configuration->httpTimeoutSeconds, true)); + $keySet = JWK::parseKeySet($this->jwks($configuration->jwksUri, $configuration->httpTimeoutSeconds, true, $correlationId)); } $key = $keySet[$kid] ?? null; if (! $key instanceof Key) { @@ -93,13 +93,13 @@ private function assertIssuerAndAudience(OidcClientConfiguration $configuration, /** @return array */ /** @return array */ - private function jwks(string $uri, int $timeoutSeconds, bool $refresh): array + private function jwks(string $uri, int $timeoutSeconds, bool $refresh, ?string $correlationId): array { if (! $refresh && isset($this->jwksByUri[$uri])) { return $this->jwksByUri[$uri]; } try { - $response = $this->http->request('GET', $uri, ['timeout' => $timeoutSeconds, 'connect_timeout' => $timeoutSeconds, 'http_errors' => false]); + $response = $this->http->request('GET', $uri, OidcHttpRequestOptions::strict($timeoutSeconds, [], $correlationId)); } catch (\Throwable $exception) { throw new OidcException('JWKS request failed.', 0, $exception); } diff --git a/src/Oidc/OidcDiscoveryClient.php b/src/Oidc/OidcDiscoveryClient.php index 5c8d71d..fddb01b 100644 --- a/src/Oidc/OidcDiscoveryClient.php +++ b/src/Oidc/OidcDiscoveryClient.php @@ -10,7 +10,7 @@ public function __construct(private readonly ClientInterface $http) { } - public function discover(string $issuer, int $timeoutSeconds = 5): OidcDiscoveryDocument + public function discover(string $issuer, int $timeoutSeconds = 5, ?string $correlationId = null): OidcDiscoveryDocument { $issuer = rtrim($issuer, '/'); if (filter_var($issuer, FILTER_VALIDATE_URL) === false || parse_url($issuer, PHP_URL_SCHEME) !== 'https') { @@ -18,12 +18,11 @@ public function discover(string $issuer, int $timeoutSeconds = 5): OidcDiscovery } try { - $response = $this->http->request('GET', $issuer.'/.well-known/openid-configuration', [ - 'timeout' => $timeoutSeconds, - 'connect_timeout' => $timeoutSeconds, - 'http_errors' => false, - 'headers' => ['Accept' => 'application/json'], - ]); + $response = $this->http->request('GET', $issuer.'/.well-known/openid-configuration', OidcHttpRequestOptions::strict( + $timeoutSeconds, + ['Accept' => 'application/json'], + $correlationId, + )); } catch (\Throwable $exception) { throw new OidcException('OIDC discovery request failed.', 0, $exception); } @@ -34,11 +33,17 @@ public function discover(string $issuer, int $timeoutSeconds = 5): OidcDiscovery } foreach (['authorization_endpoint', 'token_endpoint', 'jwks_uri'] as $field) { - if (! is_string($document[$field] ?? null) || $document[$field] === '') { + if (! is_string($document[$field] ?? null) || ! self::isHttpsUrl($document[$field])) { throw new OidcException('OIDC discovery document is missing '.$field.'.'); } } + foreach (['userinfo_endpoint', 'end_session_endpoint'] as $field) { + if (isset($document[$field]) && (! is_string($document[$field]) || ! self::isHttpsUrl($document[$field]))) { + throw new OidcException('OIDC discovery document contains an invalid '.$field.'.'); + } + } + return new OidcDiscoveryDocument( issuer: $issuer, authorizationEndpoint: (string) $document['authorization_endpoint'], @@ -48,4 +53,9 @@ public function discover(string $issuer, int $timeoutSeconds = 5): OidcDiscovery endSessionEndpoint: is_string($document['end_session_endpoint'] ?? null) ? $document['end_session_endpoint'] : null, ); } + + private static function isHttpsUrl(string $url): bool + { + return filter_var($url, FILTER_VALIDATE_URL) !== false && parse_url($url, PHP_URL_SCHEME) === 'https'; + } } diff --git a/src/Oidc/OidcHttpRequestOptions.php b/src/Oidc/OidcHttpRequestOptions.php new file mode 100644 index 0000000..e4d9f01 --- /dev/null +++ b/src/Oidc/OidcHttpRequestOptions.php @@ -0,0 +1,41 @@ + $headers + * @return array + */ + public static function strict(int $timeoutSeconds, array $headers = [], ?string $correlationId = null): array + { + if ($timeoutSeconds < 1 || $timeoutSeconds > 30) { + throw new OidcException('OIDC HTTP timeout must be between one and thirty seconds.'); + } + + if ($correlationId !== null) { + $correlationId = trim($correlationId); + if ($correlationId === '' || strlen($correlationId) > 128 || preg_match('/^[A-Za-z0-9._:-]+$/', $correlationId) !== 1) { + throw new OidcException('OIDC correlation ID is invalid.'); + } + + $headers['X-Correlation-ID'] = $correlationId; + } + + return [ + 'timeout' => $timeoutSeconds, + 'connect_timeout' => $timeoutSeconds, + 'http_errors' => false, + 'allow_redirects' => false, + 'verify' => true, + 'headers' => $headers, + ]; + } +} diff --git a/tests/OidcTransportSecurityTest.php b/tests/OidcTransportSecurityTest.php new file mode 100644 index 0000000..4fc4db6 --- /dev/null +++ b/tests/OidcTransportSecurityTest.php @@ -0,0 +1,80 @@ +client([ + new Response(200, ['Content-Type' => 'application/json'], json_encode([ + 'issuer' => 'https://identity.example.com', + 'authorization_endpoint' => 'https://identity.example.com/oauth/authorize', + 'token_endpoint' => 'https://identity.example.com/oauth/token', + 'jwks_uri' => 'https://identity.example.com/.well-known/jwks.json', + ], JSON_THROW_ON_ERROR)), + ]); + + $document = (new OidcDiscoveryClient($client))->discover('https://identity.example.com'); + + self::assertSame('https://identity.example.com/oauth/token', $document->tokenEndpoint); + } + + public function test_token_exchange_and_strict_transport_options_reject_invalid_correlation_id(): void + { + $client = $this->client([ + new Response(200, ['Content-Type' => 'application/json'], json_encode([ + 'access_token' => 'access-token', + 'id_token' => 'id-token', + 'token_type' => 'Bearer', + 'expires_in' => 300, + ], JSON_THROW_ON_ERROR)), + ]); + + $tokens = (new AuthorizationCodeClient($client))->exchange($this->configuration(), 'code', str_repeat('a', 43)); + + self::assertSame('access-token', $tokens->accessToken); + self::assertSame([ + 'timeout' => 5, + 'connect_timeout' => 5, + 'http_errors' => false, + 'allow_redirects' => false, + 'verify' => true, + 'headers' => ['Accept' => 'application/json', 'X-Correlation-ID' => 'corr-456'], + ], OidcHttpRequestOptions::strict(5, ['Accept' => 'application/json'], 'corr-456')); + + $this->expectException(OidcException::class); + OidcHttpRequestOptions::strict(5, [], "invalid\nheader"); + } + + /** @param list $responses */ + private function client(array $responses): Client + { + return new Client(['handler' => \GuzzleHttp\HandlerStack::create(new MockHandler($responses))]); + } + + private function configuration(): OidcClientConfiguration + { + return new OidcClientConfiguration( + 'https://identity.example.com', + 'client', + 'https://app.example.com/auth/oidc/callback', + 'https://identity.example.com/oauth/authorize', + 'https://identity.example.com/oauth/token', + 'https://identity.example.com/.well-known/jwks.json', + 'client-secret', + ); + } +}